@tiphareth/dsh-hardssh 0.1.2-alpha → 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.
Files changed (174) hide show
  1. package/LICENSE +30 -0
  2. package/README.md +49 -25
  3. package/SKILLS.md +140 -0
  4. package/lib/base/index.js +95 -0
  5. package/lib/client.js +1165 -1329
  6. package/lib/fs.js +307 -416
  7. package/lib/index.js +5428 -2991
  8. package/lib/ledger-B-LXlftp.js +43 -0
  9. package/lib/ledger-D2ezq1iW.js +376 -0
  10. package/lib/model-Cp7f70Mb.js +5 -0
  11. package/lib/registry-CViuzKYI.js +421 -0
  12. package/lib/subprocess.js +92 -549
  13. package/lib/types/backend.d.ts +73 -78
  14. package/lib/types/base/capability.d.ts +4 -0
  15. package/lib/types/base/ledger-router.d.ts +42 -26
  16. package/lib/types/base/ledger.d.ts +30 -19
  17. package/lib/types/base/model.d.ts +17 -8
  18. package/lib/types/base/plugin.d.ts +11 -0
  19. package/lib/types/base/registry.d.ts +28 -31
  20. package/lib/types/base/router.d.ts +0 -7
  21. package/lib/types/client/api.d.ts +8 -8
  22. package/lib/types/client/connect-host.d.ts +13 -0
  23. package/lib/types/client/index.d.ts +6 -4
  24. package/lib/types/client/locales.d.ts +15 -1
  25. package/lib/types/client/session-connect-gate.d.ts +71 -0
  26. package/lib/types/client/ssh/api.d.ts +11 -9
  27. package/lib/types/client/ssh/apply.d.ts +14 -8
  28. package/lib/types/client/ssh/locales.d.ts +8 -1
  29. package/lib/types/client/ssh/ops-tab.d.ts +34 -0
  30. package/lib/types/client/ssh/panel/ClusterTab.d.ts +3 -1
  31. package/lib/types/client/ssh/panel/ConnectionErrorDialog.d.ts +8 -0
  32. package/lib/types/client/ssh/panel/SessionSecretDialog.d.ts +4 -1
  33. package/lib/types/client/ssh/panel/SshPanel.d.ts +8 -8
  34. package/lib/types/client/ssh/panel/TerminalTab.d.ts +3 -5
  35. package/lib/types/client/ssh/panel/TransferTab.d.ts +5 -1
  36. package/lib/types/client/ssh/panel/TunnelsTab.d.ts +3 -1
  37. package/lib/types/client/ssh/session-target.d.ts +17 -0
  38. package/lib/types/client/state.d.ts +4 -1
  39. package/lib/types/client/workspace-panel-entry.d.ts +30 -0
  40. package/lib/types/client/workspace-panel.d.ts +24 -0
  41. package/lib/types/client-http.d.ts +77 -6
  42. package/lib/types/core.d.ts +5 -16
  43. package/lib/types/fs.d.ts +18 -25
  44. package/lib/types/index.d.ts +138 -9
  45. package/lib/types/ledger.d.ts +16 -111
  46. package/lib/types/protocol.d.ts +0 -5
  47. package/lib/types/providers/index.d.ts +6 -3
  48. package/lib/types/providers/local/provider.d.ts +83 -23
  49. package/lib/types/providers/ssh/provider.d.ts +24 -57
  50. package/lib/types/remote/environment.d.ts +2 -0
  51. package/lib/types/remote/remote-fs.d.ts +12 -1
  52. package/lib/types/remote/remote-process.d.ts +22 -1
  53. package/lib/types/remote/remote-subprocess.d.ts +13 -1
  54. package/lib/types/remote/remote-terminal.d.ts +4 -0
  55. package/lib/types/remote-search.d.ts +3 -3
  56. package/lib/types/routes.d.ts +36 -8
  57. package/lib/types/runtime/dsh-capabilities.d.ts +18 -0
  58. package/lib/types/runtime/workspace-core.d.ts +73 -38
  59. package/lib/types/runtime/workspace-migration.d.ts +113 -0
  60. package/lib/types/ssh/connection/lease.d.ts +23 -0
  61. package/lib/types/ssh/connection/manager.d.ts +359 -0
  62. package/lib/types/ssh/connection/pool.d.ts +8 -1
  63. package/lib/types/ssh/engine.d.ts +97 -267
  64. package/lib/types/ssh/known-hosts.d.ts +22 -5
  65. package/lib/types/ssh/local-transfer-policy.d.ts +20 -0
  66. package/lib/types/ssh/plugin.d.ts +10 -15
  67. package/lib/types/ssh/protocol.d.ts +18 -0
  68. package/lib/types/ssh/routes.d.ts +15 -7
  69. package/lib/types/ssh/sftp/service.d.ts +216 -0
  70. package/lib/types/ssh/store.d.ts +20 -8
  71. package/lib/types/ssh/terminal/service.d.ts +49 -0
  72. package/lib/types/ssh/tunnel/service.d.ts +26 -0
  73. package/lib/types/ssh/vault.d.ts +51 -4
  74. package/lib/types/subprocess.d.ts +13 -21
  75. package/lib/types/switch/switch-fs.d.ts +52 -2
  76. package/lib/types/switch/switch-subprocess.d.ts +32 -3
  77. package/lib/types/tools.d.ts +7 -6
  78. package/lib/types/workspace-tool-ops.d.ts +33 -0
  79. package/lib/types/workspace.d.ts +18 -0
  80. package/lib/vault-3gpWct2Q.js +559 -0
  81. package/lib/workspace.js +2 -0
  82. package/package.json +36 -11
  83. package/scripts/export-legacy-workspaces.mjs +136 -0
  84. package/src/backend.ts +155 -483
  85. package/src/base/capability.ts +4 -0
  86. package/src/base/ledger-router.ts +214 -65
  87. package/src/base/ledger.ts +175 -48
  88. package/src/base/model.ts +18 -8
  89. package/src/base/plugin.ts +15 -4
  90. package/src/base/registry.ts +49 -41
  91. package/src/base/router.ts +0 -8
  92. package/src/client/api.ts +8 -36
  93. package/src/client/connect-host.ts +199 -0
  94. package/src/client/directory-flow.tsx +91 -36
  95. package/src/client/index.ts +56 -53
  96. package/src/client/locales.ts +14 -0
  97. package/src/client/session-connect-gate.ts +163 -0
  98. package/src/client/ssh/api.ts +245 -108
  99. package/src/client/ssh/apply.ts +23 -24
  100. package/src/client/ssh/locales.ts +16 -2
  101. package/src/client/ssh/ops-tab.tsx +79 -0
  102. package/src/client/ssh/panel/ClusterTab.tsx +9 -20
  103. package/src/client/ssh/panel/ConnectionErrorDialog.tsx +33 -0
  104. package/src/client/ssh/panel/SessionSecretDialog.tsx +5 -1
  105. package/src/client/ssh/panel/SshPanel.tsx +81 -77
  106. package/src/client/ssh/panel/TerminalTab.tsx +18 -37
  107. package/src/client/ssh/panel/TransferTab.tsx +43 -33
  108. package/src/client/ssh/panel/TunnelsTab.tsx +9 -26
  109. package/src/client/ssh/panel/panel.module.css +90 -127
  110. package/src/client/ssh/session-target.ts +69 -0
  111. package/src/client/state.ts +9 -1
  112. package/src/client/workspace-badges.ts +99 -99
  113. package/src/client/workspace-panel-entry.tsx +91 -0
  114. package/src/client/workspace-panel.tsx +227 -0
  115. package/src/client/workspace.module.css +76 -2
  116. package/src/client-http.ts +118 -18
  117. package/src/core.ts +37 -47
  118. package/src/fs.ts +137 -85
  119. package/src/index.ts +535 -244
  120. package/src/ledger.ts +62 -416
  121. package/src/protocol.ts +0 -6
  122. package/src/providers/index.ts +8 -4
  123. package/src/providers/local/provider.ts +259 -87
  124. package/src/providers/ssh/provider.ts +158 -155
  125. package/src/remote/environment.ts +22 -1
  126. package/src/remote/remote-fs.ts +123 -34
  127. package/src/remote/remote-process.ts +265 -203
  128. package/src/remote/remote-subprocess.ts +61 -18
  129. package/src/remote/remote-terminal.ts +106 -21
  130. package/src/remote-search.ts +122 -26
  131. package/src/routes.ts +416 -395
  132. package/src/runtime/dsh-capabilities.ts +19 -0
  133. package/src/runtime/workspace-core.ts +171 -88
  134. package/src/runtime/workspace-migration.ts +472 -0
  135. package/src/ssh/connection/lease.ts +35 -0
  136. package/src/ssh/connection/manager.ts +1083 -0
  137. package/src/ssh/connection/pool.ts +341 -275
  138. package/src/ssh/engine.ts +269 -1477
  139. package/src/ssh/known-hosts.ts +42 -18
  140. package/src/ssh/local-transfer-policy.ts +83 -0
  141. package/src/ssh/plugin.ts +20 -19
  142. package/src/ssh/protocol.ts +22 -1
  143. package/src/ssh/routes.ts +419 -187
  144. package/src/ssh/sftp/service.ts +967 -0
  145. package/src/ssh/store.ts +169 -72
  146. package/src/ssh/terminal/service.ts +177 -0
  147. package/src/ssh/tools.ts +42 -20
  148. package/src/ssh/tunnel/service.ts +217 -0
  149. package/src/ssh/vault.ts +245 -91
  150. package/src/subprocess.ts +88 -71
  151. package/src/switch/switch-fs.ts +141 -10
  152. package/src/switch/switch-subprocess.ts +80 -6
  153. package/src/tools.ts +217 -221
  154. package/src/workspace-tool-ops.ts +101 -0
  155. package/src/workspace.ts +52 -0
  156. package/lib/environment-BL1jddfB.js +0 -449
  157. package/lib/switch-fs-CAJpFY9C.js +0 -193
  158. package/lib/switch-fs-RrZtG2gv.js +0 -210
  159. package/lib/types/client/manager-button.d.ts +0 -32
  160. package/lib/types/client/ssh/mount.d.ts +0 -13
  161. package/lib/types/client/ssh/panel/HostsTab.d.ts +0 -10
  162. package/lib/types/client/ssh/panel/controller.d.ts +0 -23
  163. package/lib/types/client/ssh/sidebar-entry.d.ts +0 -25
  164. package/lib/types/client/workspace-gate.d.ts +0 -15
  165. package/lib/types/remote-runner.d.ts +0 -83
  166. package/lib/types/seam-state.d.ts +0 -69
  167. package/src/client/manager-button.tsx +0 -269
  168. package/src/client/ssh/mount.tsx +0 -83
  169. package/src/client/ssh/panel/HostsTab.tsx +0 -236
  170. package/src/client/ssh/panel/controller.ts +0 -48
  171. package/src/client/ssh/sidebar-entry.ts +0 -123
  172. package/src/client/workspace-gate.ts +0 -232
  173. package/src/remote-runner.ts +0 -201
  174. package/src/seam-state.ts +0 -185
package/lib/fs.js CHANGED
@@ -1,478 +1,369 @@
1
- import { i as shellQuote, o as isPathUnderAnchor } from "./environment-BL1jddfB.js";
2
- import { n as SwitchFileSystem, r as WFS_NAMESPACE_MARKER } from "./switch-fs-RrZtG2gv.js";
3
- import { createHash, randomUUID } from "node:crypto";
4
- import { posix } from "node:path";
5
- import { FileSystem, FsError, FsTargetKey, FsVersion } from "@deepseek-ai/dsh-fs";
1
+ import { i as isPathUnderAnchor } from "./ledger-D2ezq1iW.js";
2
+ import { t as anchorRoot } from "./ledger-B-LXlftp.js";
3
+ import { n as legacyVaultPath, r as vaultDirectory } from "./vault-3gpWct2Q.js";
4
+ import { join } from "node:path";
5
+ import { homedir } from "node:os";
6
+ import { FileSystem } from "@deepseek-ai/dsh-fs";
6
7
  import { SandboxedFileSystem } from "@deepseek-ai/dsh-fs-sandbox";
7
- //#region src/remote/remote-fs.ts
8
- /**
9
- * Remote filesystem provider for the `ctx.fs` capability seam: paths,
10
- * contents, and atomic staging files live on the remote host, reached through
11
- * the dsh-ssh engine's SFTP/exec primitives. Ported and adapted from
12
- * UynajGI/dsh-ssh (MIT, https://github.com/UynajGI/dsh-ssh) — the seam
13
- * contract (targets, versions, atomic writes, CRLF handling, canonical path
14
- * transport) is preserved; the connection owner is replaced by the shared
15
- * SshEngine and the working directory follows the mode store's remote root.
16
- *
17
- * @module dsh-hardssh/remote-fs
18
- */
19
- const BINARY_SAMPLE_BYTES = 8192;
20
- const BASE64 = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
21
- function assertNotAborted(signal, operation) {
22
- if (signal?.aborted === true) throw new FsError(`${operation} aborted`, "FS_ABORTED");
8
+ /** Strict WFS key prefix (includes the double slash + separator). */
9
+ const WFS_NAMESPACE_MARKER = `wfs://`;
10
+ function normalizeForEquality(path) {
11
+ return path.replace(/\\/g, "/").replace(/\/+$/, "");
23
12
  }
24
- function normalizeLineEndings(value) {
25
- return value.replaceAll("\r\n", "\n");
13
+ /** Whether `path` is `root` itself or under it. Case-folding is applied ONLY
14
+ * when the root is a Windows-style path (drive letter) — no OS guessing. */
15
+ function isUnder(root, path) {
16
+ if (root === void 0 || root === "") return false;
17
+ const r = normalizeForEquality(root);
18
+ const p = normalizeForEquality(path);
19
+ const fold = /^[a-zA-Z]:\//.test(r);
20
+ const nr = fold ? r.toLowerCase() : r;
21
+ const np = fold ? p.toLowerCase() : p;
22
+ return np === nr || np.startsWith(nr + "/");
26
23
  }
27
- function detectsCrlf(value) {
28
- const sample = value.slice(0, 4096);
29
- const crlf = sample.split("\r\n").length - 1;
30
- return crlf > sample.split("\n").length - 1 - crlf;
24
+ /** Membership test for a REMOTE world. Default: EVERYTHING routes remote —
25
+ * a bound session's work lives on the server (including absolute paths
26
+ * outside the workspace tree, e.g. another project dir on that host).
27
+ * The ONLY exclusion is the DECLARED client infrastructure (`localRoots`:
28
+ * dsh's own `~/.dsh` with skills + harness state, plugin configs, …),
29
+ * which stays local on any client OS. `localRootExclusions` subtract the
30
+ * workspace-anchor window from that exception, because anchors physically
31
+ * live under `~/.dsh` and must never be treated as local. No host-OS
32
+ * syntax guessing. */
33
+ function belongsToRemoteWorld(path, deps) {
34
+ if (deps.localRootExclusions?.some((root) => isUnder(root, path)) === true) return true;
35
+ if (deps.localRoots !== void 0 && deps.localRoots.some((root) => isUnder(root, path))) return false;
36
+ return true;
31
37
  }
32
- function restoreLineEndings(value, crlf) {
33
- return crlf ? normalizeLineEndings(value).replaceAll("\n", "\r\n") : value;
34
- }
35
- function decodeText(bytes, displayPath) {
36
- if (bytes.subarray(0, BINARY_SAMPLE_BYTES).includes(0)) throw new FsError(`cannot read "${displayPath}": binary file`, "FS_NOT_TEXT");
37
- try {
38
- return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
39
- } catch (error) {
40
- throw new FsError(`cannot read "${displayPath}": invalid UTF-8 text`, "FS_NOT_TEXT", { cause: error });
41
- }
42
- }
43
- /** Decode a base64-wrapped NUL-terminated canonical path from `realpath -mz`. */
44
- function decodeCanonicalPath(encoded) {
45
- if (encoded.length === 0 || !BASE64.test(encoded)) throw new Error("fs-ssh: canonical path transport returned invalid base64");
46
- const framed = Buffer.from(encoded, "base64");
47
- if (framed.toString("base64") !== encoded || framed.length < 2 || framed.at(-1) !== 0 || framed.subarray(0, -1).includes(0)) throw new Error("fs-ssh: canonical path transport returned invalid NUL framing");
48
- let path;
49
- try {
50
- path = new TextDecoder("utf-8", { fatal: true }).decode(framed.subarray(0, -1));
51
- } catch (error) {
52
- throw new Error("fs-ssh: canonical path is not valid UTF-8", { cause: error });
38
+ /** The routing filesystem facade. */
39
+ var SwitchFileSystem = class extends FileSystem {
40
+ deps;
41
+ local;
42
+ constructor(ctx, deps) {
43
+ super(ctx);
44
+ this.deps = deps;
45
+ this.local = deps.local;
53
46
  }
54
- if (!posix.isAbsolute(path)) throw new Error("fs-ssh: canonical path is not absolute");
55
- return path;
56
- }
57
- function entryType(stats) {
58
- if (stats.isFile()) return "file";
59
- if (stats.isDirectory()) return "directory";
60
- return "other";
61
- }
62
- function entryVersion(stats, path) {
63
- return FsVersion(`ssh:${createHash("sha256").update(JSON.stringify([
64
- path,
65
- stats.size,
66
- stats.mtime,
67
- stats.mode
68
- ])).digest("hex")}`);
69
- }
70
- function mapError(error, operation, displayPath, signal) {
71
- if (error instanceof FsError) return error;
72
- if (signal?.aborted === true) return new FsError(`${operation} aborted`, "FS_ABORTED", { cause: error });
73
- const code = String(error.code ?? "");
74
- const message = String(error);
75
- if (/NO_SUCH_FILE|ENOENT|no such file|does not exist/i.test(`${code} ${message}`)) return new FsError(`cannot ${operation} "${displayPath}": not found`, "FS_NOT_FOUND", { cause: error });
76
- if (/PERMISSION_DENIED|EACCES|permission denied/i.test(`${code} ${message}`)) return new FsError(`cannot ${operation} "${displayPath}": permission denied`, "FS_PERMISSION_DENIED", { cause: error });
77
- return new FsError(`cannot ${operation} "${displayPath}": ${message}`, "FS_IO_ERROR", { cause: error });
78
- }
79
- function literalEdit(content, request, displayPath) {
80
- const oldString = normalizeLineEndings(request.oldString);
81
- const newString = normalizeLineEndings(request.newString);
82
- if (oldString.length === 0) throw new FsError(`cannot edit "${displayPath}": old_string must be non-empty`, "FS_EDIT_NOT_FOUND");
83
- let matches = 0;
84
- let offset = 0;
85
- while (true) {
86
- const found = content.indexOf(oldString, offset);
87
- if (found < 0) break;
88
- matches += 1;
89
- offset = found + oldString.length;
47
+ /** The deployment's local backend is still a sandboxed filesystem, and
48
+ * `dsh-tool-fs` reads this capability fact ONCE at apply() to decide whether
49
+ * to advertise `sandbox_permissions` / `justification` at all. Returning
50
+ * undefined here disabled that escalation entry for EVERY session,
51
+ * including purely local ones, so the local backend's mode is the honest
52
+ * answer. Remote worlds are not confined by the local sandbox, so a wider
53
+ * per-call policy is meaningless for them and is dropped at the call site
54
+ * instead of being silently swallowed by a 4-parameter remote backend. */
55
+ get sandboxMode() {
56
+ return this.local.sandboxMode;
90
57
  }
91
- if (matches === 0) throw new FsError(`cannot edit "${displayPath}": old_string was not found`, "FS_EDIT_NOT_FOUND");
92
- if (!request.replaceAll && matches !== 1) throw new FsError(`cannot edit "${displayPath}": old_string matched ${matches} times`, "FS_AMBIGUOUS_EDIT");
93
- return request.replaceAll ? content.split(oldString).join(newString) : content.replace(oldString, newString);
94
- }
95
- /** Whether one SFTP/exec error means "path absent". */
96
- function isNotFound(error) {
97
- const code = String(error.code ?? "");
98
- const message = String(error);
99
- return /NO_SUCH_FILE|ENOENT|no such file|does not exist/i.test(`${code} ${message}`);
100
- }
101
- /**
102
- * Remote filesystem backend over the dsh-ssh engine. The working directory
103
- * follows the mode store: relative paths resolve against the resolved remote
104
- * root; a POSIX-absolute cwd override is honored; local (Windows) cwds are
105
- * ignored so the model's relative-path habit keeps working remotely.
106
- */
107
- var SshFileSystem = class extends FileSystem {
108
- engine;
109
- getState;
110
- locks = /* @__PURE__ */ new Map();
111
- constructor(ctx, engine, getState) {
112
- super(ctx);
113
- this.engine = engine;
114
- this.getState = getState;
58
+ /** Encode a raw key into the world's namespace. */
59
+ encode(rawKey, namespace) {
60
+ return namespace === "" ? rawKey : `${namespace}${rawKey}`;
115
61
  }
116
- /** The active remote execution world (throws when not in remote mode). */
117
- current() {
118
- const state = this.getState();
119
- if (state.mode !== "remote" || state.alias === void 0) throw new FsError("not in remote mode — switch the GUI to SSH mode first", "FS_IO_ERROR");
120
- if (state.remoteRoot === void 0) throw new FsError("remote workspace root is not set", "FS_IO_ERROR");
62
+ /** Decode a namespaced key back to (rawKey, world). Supports both the
63
+ * current `wfs://<id>/…` form and the legacy `ssh:<recordId>:…` form. */
64
+ decode(key) {
65
+ if (key.startsWith(WFS_NAMESPACE_MARKER)) {
66
+ const rest = key.slice(WFS_NAMESPACE_MARKER.length);
67
+ const end = rest.indexOf("/");
68
+ if (end > 0) {
69
+ const namespace = `wfs://${rest.slice(0, end + 1)}`;
70
+ const world = this.deps.worldForNamespace(namespace);
71
+ if (world !== void 0) return {
72
+ rawKey: rest.slice(end + 1),
73
+ world
74
+ };
75
+ }
76
+ return;
77
+ }
78
+ if (key.startsWith("ssh:")) {
79
+ const end = key.indexOf(":", 4);
80
+ if (end > 0) {
81
+ const namespace = key.slice(0, end + 1);
82
+ const world = this.deps.worldForNamespace(namespace);
83
+ if (world !== void 0) return {
84
+ rawKey: key.slice(namespace.length),
85
+ world
86
+ };
87
+ }
88
+ return;
89
+ }
90
+ this.assertNotDenied(key, "");
121
91
  return {
122
- alias: state.alias,
123
- remoteRoot: state.remoteRoot
92
+ rawKey: key,
93
+ world: {
94
+ backend: this.local,
95
+ namespace: ""
96
+ }
124
97
  };
125
98
  }
126
99
  /**
127
- * Resolve the working directory for a path: a POSIX-absolute cwd wins;
128
- * anything else (relative or a local Windows path) falls back to the
129
- * remote root.
100
+ * Refuse a protected client directory before any backend sees it.
101
+ *
102
+ * Only a LOCAL world is checked: for a remote world the path is a path on the
103
+ * SERVER, which may legitimately look like a client path (both can be
104
+ * `/home/me/.dsh/…`).
105
+ *
106
+ * @param path - the path (or canonical target key) about to be dispatched.
107
+ * @param namespace - the resolved world's namespace ('' = local).
108
+ */
109
+ assertNotDenied(path, namespace) {
110
+ if (namespace !== "") return;
111
+ if (this.deps.deniedRoots?.find((root) => isUnder(root, path)) !== void 0) throw new Error(`fs-hardssh: '${path}' is not accessible through the workspace filesystem (protected client directory)`);
112
+ }
113
+ /**
114
+ * The world owning one call: an explicit anchor path (this workspace's or a
115
+ * SIBLING's) wins over the session cwd, because anchors are physically under
116
+ * the local-infrastructure root and would otherwise be carved out as local.
117
+ * Falls back to the session cwd's world, then to the caller's default.
130
118
  */
131
- resolveRemoteCwd(cwd) {
132
- if (cwd !== void 0 && posix.isAbsolute(cwd)) return cwd;
133
- return this.current().remoteRoot;
119
+ worldForPath(path, cwd) {
120
+ const anchorWorld = this.deps.worldForAnchorPath?.(path);
121
+ if (anchorWorld !== void 0) {
122
+ this.assertNotDenied(path, anchorWorld.namespace);
123
+ return anchorWorld;
124
+ }
125
+ const pathWorld = this.deps.worldFor(path);
126
+ if (pathWorld.namespace !== "" && pathWorld.anchorPath !== void 0 && pathWorld.remoteRoot !== void 0) return pathWorld;
127
+ const chosen = this.deps.worldFor(cwd);
128
+ this.assertNotDenied(path, chosen.namespace);
129
+ return chosen;
134
130
  }
135
131
  async resolve(path, opts) {
136
- assertNotAborted(opts?.signal, "resolve");
137
- if (path.trim().length === 0) throw new FsError("file_path must be a non-empty string", "FS_NOT_FOUND");
138
- const displayPath = posix.resolve(this.resolveRemoteCwd(opts?.cwd), path);
139
- try {
140
- const targetKey = await this.canonicalPath(displayPath, opts?.signal);
141
- assertNotAborted(opts?.signal, "resolve");
132
+ const world = this.worldForPath(path, opts?.cwd);
133
+ const effectivePath = world.anchorPath !== void 0 && world.remoteRoot !== void 0 ? this.translateAnchorPath(world.anchorPath, world.remoteRoot, path) : path;
134
+ if (world.namespace !== "" && !belongsToRemoteWorld(effectivePath, this.deps)) {
135
+ this.assertNotDenied(path, "");
136
+ const raw = await this.local.resolve(path, opts);
142
137
  return {
143
- targetKey: FsTargetKey(targetKey),
144
- displayPath
138
+ targetKey: String(raw.targetKey),
139
+ displayPath: raw.displayPath
145
140
  };
146
- } catch (error) {
147
- throw mapError(error, "resolve", displayPath, opts?.signal);
148
141
  }
142
+ const raw = await world.backend.resolve(effectivePath, opts);
143
+ return {
144
+ targetKey: this.encode(String(raw.targetKey), world.namespace),
145
+ displayPath: raw.displayPath
146
+ };
147
+ }
148
+ /** Rewrite `<anchor>/<rest>` to `<remoteRoot>/<rest>` when `path` is the
149
+ * anchor or under it; otherwise return `path` unchanged (remote absolute
150
+ * paths, relative paths, and foreign local paths all pass through). */
151
+ translateAnchorPath(anchor, remoteRoot, path) {
152
+ const norm = (value) => value.replace(/[\\/]+$/, "");
153
+ const a = norm(anchor);
154
+ const isWin = a.includes("\\");
155
+ const normPath = (value) => {
156
+ let out = norm(value);
157
+ if (isWin) out = out.replace(/\//g, "\\");
158
+ return out;
159
+ };
160
+ const p = normPath(path);
161
+ const aa = isWin ? a.toLowerCase() : a;
162
+ const pp = isWin ? p.toLowerCase() : p;
163
+ if (pp === aa) return remoteRoot;
164
+ if (pp.startsWith(`${aa}\\`) || pp.startsWith(`${aa}/`)) {
165
+ const tail = p.slice(a.length).replace(/^[\\/]+/, "").replace(/\\/g, "/");
166
+ return tail === "" ? remoteRoot : `${remoteRoot.replace(/\/+$/, "")}/${tail}`;
167
+ }
168
+ return path;
149
169
  }
150
170
  processPath(target) {
151
- return String(target.targetKey);
171
+ const decoded = this.decode(String(target.targetKey));
172
+ if (decoded === void 0) throw new Error("fs-ssh: cannot route stale workspace target");
173
+ return decoded.world.backend.processPath({
174
+ targetKey: decoded.rawKey,
175
+ displayPath: target.displayPath
176
+ });
152
177
  }
153
178
  fileUrl(target) {
154
- const path = this.processPath(target);
155
- if (!posix.isAbsolute(path)) throw new Error(`fs-ssh: expected an absolute process path: ${JSON.stringify(path)}`);
156
- return `file://${path.split("/").map((segment) => encodeURIComponent(segment)).join("/")}`;
179
+ const decoded = this.decode(String(target.targetKey));
180
+ if (decoded === void 0) throw new Error("fs-ssh: cannot route stale workspace target");
181
+ return decoded.world.backend.fileUrl({
182
+ targetKey: decoded.rawKey,
183
+ displayPath: target.displayPath
184
+ });
157
185
  }
158
186
  contains(parent, child) {
159
- const relative = posix.relative(this.processPath(parent), this.processPath(child));
160
- return relative === "" || relative !== ".." && !relative.startsWith("../") && !posix.isAbsolute(relative);
187
+ const p = this.decode(String(parent.targetKey));
188
+ const c = this.decode(String(child.targetKey));
189
+ if (p === void 0 || c === void 0 || p.world.backend !== c.world.backend) return false;
190
+ return p.world.backend.contains({
191
+ targetKey: p.rawKey,
192
+ displayPath: parent.displayPath
193
+ }, {
194
+ targetKey: c.rawKey,
195
+ displayPath: child.displayPath
196
+ });
161
197
  }
162
198
  async stat(target, signal) {
163
- assertNotAborted(signal, "stat");
164
- const stats = await this.probe(String(target.targetKey), target.displayPath, signal);
165
- if (stats === void 0) return void 0;
166
- return {
167
- version: entryVersion(stats, String(target.targetKey)),
168
- type: entryType(stats),
169
- ...stats.isFile() ? { size: stats.size } : {}
170
- };
199
+ const decoded = this.decode(String(target.targetKey));
200
+ if (decoded === void 0) return void 0;
201
+ return decoded.world.backend.stat({
202
+ targetKey: decoded.rawKey,
203
+ displayPath: target.displayPath
204
+ }, signal);
171
205
  }
172
206
  async lstat(path, opts, signal) {
173
- assertNotAborted(signal, "lstat");
174
- if (path.trim().length === 0) throw new FsError("file_path must be a non-empty string", "FS_NOT_FOUND");
175
- const displayPath = posix.resolve(this.resolveRemoteCwd(opts?.cwd), path);
176
- const { alias } = this.current();
177
- try {
178
- const info = await this.engine.lstat(alias, displayPath);
179
- assertNotAborted(signal, "lstat");
180
- if (info === void 0) return void 0;
181
- const type = info.type === "symlink" ? "symlink" : info.type === "directory" ? "directory" : info.type === "file" ? "file" : "other";
182
- return {
183
- version: entryVersion(this.asStats({
184
- type: info.type,
185
- size: info.size,
186
- mtimeMs: info.mtimeMs,
187
- mode: info.mode
188
- }), displayPath),
189
- type,
190
- ...type === "file" ? { size: info.size } : {}
191
- };
192
- } catch (error) {
193
- if (isNotFound(error)) return void 0;
194
- throw mapError(error, "lstat", displayPath, signal);
207
+ const world = this.worldForPath(path, opts?.cwd);
208
+ const effectivePath = world.anchorPath !== void 0 && world.remoteRoot !== void 0 ? this.translateAnchorPath(world.anchorPath, world.remoteRoot, path) : path;
209
+ if (world.namespace !== "" && !belongsToRemoteWorld(effectivePath, this.deps)) {
210
+ this.assertNotDenied(path, "");
211
+ return this.local.lstat(path, opts, signal);
195
212
  }
213
+ return world.backend.lstat(effectivePath, opts, signal);
196
214
  }
197
215
  async readText(target, signal) {
198
- await this.requireRegular(target, signal);
199
- const bytes = await this.readBytesRaw(target, signal, Number.POSITIVE_INFINITY);
200
- assertNotAborted(signal, "read");
201
- return decodeText(bytes, target.displayPath);
202
- }
203
- async readBytes(target, signal, maxBytes) {
204
- const info = await this.requireRegular(target, signal);
205
- if (info.size !== void 0 && info.size > maxBytes) throw new FsError(`cannot read "${target.displayPath}": ${info.size} bytes exceeds the ${maxBytes}-byte limit`, "FS_TOO_LARGE");
206
- const bytes = await this.readBytesRaw(target, signal, maxBytes);
207
- assertNotAborted(signal, "read");
208
- return bytes;
216
+ const decoded = this.decode(String(target.targetKey));
217
+ if (decoded === void 0) throw new Error("fs-ssh: cannot route target");
218
+ return decoded.world.backend.readText({
219
+ targetKey: decoded.rawKey,
220
+ displayPath: target.displayPath
221
+ }, signal);
209
222
  }
210
223
  async streamText(target, signal) {
211
- await this.requireRegular(target, signal);
212
- const { alias } = this.current();
213
- const displayPath = target.displayPath;
214
- const stream = await this.engine.readStream(alias, String(target.targetKey));
215
- return { async *[Symbol.asyncIterator]() {
216
- const decoder = new TextDecoder("utf-8", { fatal: true });
217
- let sampledBytes = 0;
218
- try {
219
- for await (const chunk of stream) {
220
- assertNotAborted(signal, "read");
221
- const bytes = Buffer.from(chunk);
222
- if (sampledBytes < BINARY_SAMPLE_BYTES) {
223
- const sample = bytes.subarray(0, BINARY_SAMPLE_BYTES - sampledBytes);
224
- if (sample.includes(0)) throw new FsError(`cannot read "${displayPath}": binary file`, "FS_NOT_TEXT");
225
- sampledBytes += sample.length;
226
- }
227
- try {
228
- const text = decoder.decode(bytes, { stream: true });
229
- if (text.length > 0) yield text;
230
- } catch (error) {
231
- throw new FsError(`cannot read "${displayPath}": invalid UTF-8 text`, "FS_NOT_TEXT", { cause: error });
232
- }
233
- }
234
- try {
235
- decoder.decode();
236
- } catch (error) {
237
- throw new FsError(`cannot read "${displayPath}": invalid UTF-8 text`, "FS_NOT_TEXT", { cause: error });
238
- }
239
- } catch (error) {
240
- throw mapError(error, "read", displayPath, signal);
241
- }
242
- } };
224
+ const decoded = this.decode(String(target.targetKey));
225
+ if (decoded === void 0) throw new Error("fs-ssh: cannot route target");
226
+ return decoded.world.backend.streamText({
227
+ targetKey: decoded.rawKey,
228
+ displayPath: target.displayPath
229
+ }, signal);
230
+ }
231
+ async readBytes(target, signal, maxBytes) {
232
+ const decoded = this.decode(String(target.targetKey));
233
+ if (decoded === void 0) throw new Error("fs-ssh: cannot route target");
234
+ return decoded.world.backend.readBytes({
235
+ targetKey: decoded.rawKey,
236
+ displayPath: target.displayPath
237
+ }, signal, maxBytes);
243
238
  }
244
239
  async listDir(target, signal) {
245
- const info = await this.stat(target, signal);
246
- if (info === void 0) throw new FsError(`cannot list "${target.displayPath}": not found`, "FS_NOT_FOUND");
247
- if (info.type !== "directory") throw new FsError(`cannot list "${target.displayPath}": not a directory`, "FS_NOT_DIRECTORY");
248
- const { alias } = this.current();
249
- try {
250
- const listed = await this.engine.ls(alias, String(target.targetKey));
251
- assertNotAborted(signal, "list");
252
- const displayPaths = listed.map((entry) => posix.join(target.displayPath, entry.name));
253
- const canonicalPaths = await this.engine.realpaths(alias, displayPaths);
254
- const entries = [];
255
- for (let i = 0; i < listed.length; i += 1) {
256
- const entry = listed[i];
257
- const displayPath = displayPaths[i];
258
- const canonical = canonicalPaths[i];
259
- const stats = this.asStats({
260
- type: entry.type === "dir" ? "directory" : entry.type === "file" ? "file" : "other",
261
- size: entry.size,
262
- mtimeMs: entry.mtimeMs,
263
- mode: entry.mode ?? 384
264
- });
265
- entries.push({
266
- name: entry.name,
267
- type: entryType(stats),
268
- target: {
269
- targetKey: FsTargetKey(canonical),
270
- displayPath
271
- },
272
- version: entryVersion(stats, canonical),
273
- ...entry.type === "file" ? { size: entry.size } : {}
274
- });
240
+ const decoded = this.decode(String(target.targetKey));
241
+ if (decoded === void 0) throw new Error("fs-ssh: cannot route target");
242
+ const entries = await decoded.world.backend.listDir({
243
+ targetKey: decoded.rawKey,
244
+ displayPath: target.displayPath
245
+ }, signal);
246
+ if (decoded.world.namespace === "") return entries;
247
+ return entries.map((entry) => ({
248
+ ...entry,
249
+ target: {
250
+ targetKey: this.encode(String(entry.target.targetKey), decoded.world.namespace),
251
+ displayPath: entry.target.displayPath
275
252
  }
276
- return entries.sort((left, right) => left.name.localeCompare(right.name));
277
- } catch (error) {
278
- throw mapError(error, "list", target.displayPath, signal);
279
- }
280
- }
281
- async writeText(target, content, expected, signal) {
282
- return this.withLock(String(target.targetKey), async () => {
283
- const existing = await this.probe(String(target.targetKey), target.displayPath, signal);
284
- if (existing !== void 0 && !existing.isFile()) throw new FsError(`cannot write "${target.displayPath}": not a regular file`, "FS_NOT_REGULAR_FILE");
285
- this.checkWriteIntent(existing, expected, target);
286
- const before = existing === void 0 ? null : await this.readForDiff(target, signal);
287
- const version = await this.writeAtomic(target, content, existing, expected?.kind === "createIfAbsent", signal);
288
- return {
289
- operation: existing === void 0 ? "create" : "update",
290
- version,
291
- before,
292
- after: normalizeLineEndings(content)
293
- };
294
- });
295
- }
296
- async editText(target, edit, expected, signal) {
297
- return this.withLock(String(target.targetKey), async () => {
298
- const existing = await this.probe(String(target.targetKey), target.displayPath, signal);
299
- if (existing === void 0) throw new FsError(`cannot edit "${target.displayPath}": file changed since it was read`, "FS_STALE_VERSION");
300
- if (!existing.isFile()) throw new FsError(`cannot edit "${target.displayPath}": not a regular file`, "FS_NOT_REGULAR_FILE");
301
- if (expected !== void 0 && entryVersion(existing, String(target.targetKey)) !== expected.version) throw new FsError(`cannot edit "${target.displayPath}": file changed since it was read`, "FS_STALE_VERSION");
302
- const raw = await this.readForEdit(target, signal);
303
- const before = normalizeLineEndings(raw);
304
- const after = literalEdit(before, edit, target.displayPath);
305
- const storage = restoreLineEndings(after, detectsCrlf(raw));
306
- return {
307
- version: await this.writeAtomic(target, storage, existing, false, signal),
308
- before,
309
- after
310
- };
311
- });
312
- }
313
- async withLock(targetKey, operation) {
314
- const run = (this.locks.get(targetKey) ?? Promise.resolve()).then(operation, operation);
315
- const tail = run.then(() => void 0, () => void 0);
316
- this.locks.set(targetKey, tail);
317
- try {
318
- return await run;
319
- } finally {
320
- if (this.locks.get(targetKey) === tail) this.locks.delete(targetKey);
321
- }
322
- }
323
- async canonicalPath(path, signal) {
324
- const { alias } = this.current();
325
- const result = await this.engine.exec(alias, `set -o pipefail; realpath -mz -- ${shellQuote(path)} | base64 -w0`, 1e4);
326
- signal?.throwIfAborted();
327
- if (!result.success || result.exitCode !== 0) throw new Error(result.stderr || `realpath failed for ${path}`);
328
- return decodeCanonicalPath(result.stdout.trim());
329
- }
330
- async probe(path, displayPath, signal) {
331
- assertNotAborted(signal, "stat");
332
- const { alias } = this.current();
333
- try {
334
- const info = await this.engine.stat(alias, path);
335
- assertNotAborted(signal, "stat");
336
- return this.asStats({
337
- type: info.type,
338
- size: info.size,
339
- mtimeMs: info.mtimeMs,
340
- mode: info.mode
341
- });
342
- } catch (error) {
343
- if (isNotFound(error)) return void 0;
344
- throw mapError(error, "stat", displayPath, signal);
345
- }
253
+ }));
346
254
  }
347
- async requireRegular(target, signal) {
348
- const info = await this.stat(target, signal);
349
- if (info === void 0) throw new FsError(`cannot read "${target.displayPath}": not found`, "FS_NOT_FOUND");
350
- if (info.type !== "file") throw new FsError(`cannot read "${target.displayPath}": not a regular file`, "FS_NOT_REGULAR_FILE");
351
- return info;
255
+ async writeText(target, content, expected, signal, sandboxPolicy) {
256
+ const decoded = this.decode(String(target.targetKey));
257
+ if (decoded === void 0) throw new Error("fs-ssh: cannot route target");
258
+ const policy = decoded.world.namespace === "" ? sandboxPolicy : void 0;
259
+ return decoded.world.backend.writeText({
260
+ targetKey: decoded.rawKey,
261
+ displayPath: target.displayPath
262
+ }, content, expected, signal, policy);
352
263
  }
353
- async readBytesRaw(target, signal, maxBytes) {
354
- const { alias } = this.current();
355
- try {
356
- const data = await this.engine.readFile(alias, String(target.targetKey));
357
- assertNotAborted(signal, "read");
358
- if (data.content.length > maxBytes) throw new FsError(`cannot read "${target.displayPath}": content exceeds the ${maxBytes}-byte limit`, "FS_TOO_LARGE");
359
- return data.content;
360
- } catch (error) {
361
- throw mapError(error, "read", target.displayPath, signal);
362
- }
363
- }
364
- checkWriteIntent(existing, expected, target) {
365
- if (expected?.kind === "createIfAbsent" && existing !== void 0) throw new FsError(`cannot overwrite existing "${target.displayPath}" without reading it first`, "FS_NOT_OBSERVED");
366
- if (expected?.kind === "replaceIfVersion") {
367
- if (existing === void 0 || entryVersion(existing, String(target.targetKey)) !== expected.version) throw new FsError(`cannot write "${target.displayPath}": file changed since it was read`, "FS_STALE_VERSION");
368
- }
369
- }
370
- async readForDiff(target, signal) {
371
- try {
372
- return normalizeLineEndings(decodeText(await this.readBytesRaw(target, signal, Number.POSITIVE_INFINITY), target.displayPath));
373
- } catch (error) {
374
- if (error instanceof FsError && error.code === "FS_NOT_TEXT") return null;
375
- throw mapError(error, "read", target.displayPath, signal);
376
- }
377
- }
378
- async readForEdit(target, signal) {
379
- return decodeText(await this.readBytesRaw(target, signal, Number.POSITIVE_INFINITY), target.displayPath);
380
- }
381
- async writeAtomic(target, content, existing, createIfAbsent, signal) {
382
- assertNotAborted(signal, "write");
383
- const { alias } = this.current();
384
- const targetPath = String(target.targetKey);
385
- const stagingDirectory = posix.join(posix.dirname(targetPath), `.dsh-${randomUUID()}.tmp`);
386
- const temporary = posix.join(stagingDirectory, "content");
387
- let stagingCreated = false;
388
- try {
389
- await this.engine.mkdir(alias, stagingDirectory);
390
- stagingCreated = true;
391
- await this.engine.writeFile(alias, temporary, Buffer.from(content, "utf8"));
392
- assertNotAborted(signal, "write");
393
- const mode = existing === void 0 ? 384 : existing.mode & 511;
394
- await this.engine.exec(alias, `chmod ${mode.toString(8)} -- ${shellQuote(temporary)}`, 1e4);
395
- assertNotAborted(signal, "write");
396
- if (createIfAbsent) {
397
- const publication = await this.engine.exec(alias, `if ln -T -- ${shellQuote(temporary)} ${shellQuote(targetPath)}; then printf created; elif test -e ${shellQuote(targetPath)} || test -L ${shellQuote(targetPath)}; then printf exists; else exit 1; fi`, 1e4);
398
- if (publication.stdout.trim() === "exists") throw new FsError(`cannot overwrite existing "${target.displayPath}" without reading it first`, "FS_NOT_OBSERVED");
399
- if (publication.stdout.trim() !== "created") throw new Error("guarded create returned an invalid publication result");
400
- } else await this.engine.rename(alias, temporary, targetPath);
401
- assertNotAborted(signal, "write");
402
- await this.removeStaging(stagingDirectory);
403
- const committed = await this.probe(targetPath, target.displayPath, signal);
404
- if (committed === void 0) throw new FsError(`cannot write "${target.displayPath}": commit produced no file`, "FS_IO_ERROR");
405
- return entryVersion(committed, targetPath);
406
- } catch (error) {
407
- if (stagingCreated) await this.removeStaging(stagingDirectory);
408
- throw mapError(error, "write", target.displayPath, signal);
409
- }
410
- }
411
- async removeStaging(directory) {
412
- const { alias } = this.current();
413
- try {
414
- await this.engine.rm(alias, directory, true);
415
- } catch {}
416
- }
417
- /** Normalize an engine stat/ls shape into the RemoteStats the helpers expect. */
418
- asStats(info) {
419
- const isDir = info.type === "dir" || info.type === "directory";
420
- const isFile = info.type === "file";
421
- return {
422
- isFile: () => isFile,
423
- isDirectory: () => isDir,
424
- isSymbolicLink: () => info.type === "symlink",
425
- size: info.size,
426
- mtime: Math.round(info.mtimeMs / 1e3),
427
- mode: info.mode
428
- };
264
+ async editText(target, edit, expected, signal, sandboxPolicy) {
265
+ const decoded = this.decode(String(target.targetKey));
266
+ if (decoded === void 0) throw new Error("fs-ssh: cannot route target");
267
+ const policy = decoded.world.namespace === "" ? sandboxPolicy : void 0;
268
+ return decoded.world.backend.editText({
269
+ targetKey: decoded.rawKey,
270
+ displayPath: target.displayPath
271
+ }, edit, expected, signal, policy);
429
272
  }
430
273
  };
431
274
  //#endregion
432
275
  //#region src/fs.ts
433
276
  /** Stable cordis plugin name. */
434
277
  const name = "hardssh-fs";
435
- /** Services required: the shared workspace core (mode store + engine) and the
436
- * sandbox policy the local backend (`SandboxedFileSystem`) consumes. */
437
- const inject = ["hardsshCore", "sandboxPolicy"];
438
- /** A remote backend fixed to one SSH workspace's alias + remote root. */
439
- function fixedRemoteState(alias, remoteRoot) {
440
- return () => ({
441
- mode: "remote",
442
- alias,
443
- remoteRoot
444
- });
278
+ /** The generic workspace core plus the policy used by the local fallback. */
279
+ const inject = ["sandboxPolicy", "workspaceCore"];
280
+ /** The record behind a router connection (sync snapshot lookup). */
281
+ function genericRecordFor(core, id) {
282
+ return core.ledger.snapshotSync().records.find((record) => record.id === id);
445
283
  }
446
- /** Mount the switching filesystem facade. */
284
+ /** Resolve the fs world owning a cwd, or undefined when it is definitely local. */
285
+ function genericFsWorldFor(core, cwd, reservedAnchorRoots) {
286
+ if (!core.isReady()) {
287
+ if (cwd !== void 0 && reservedAnchorRoots.some((root) => isPathUnderAnchor(root, cwd))) throw new Error("fs-ssh: workspace routing is unavailable while the generic workspace core is not ready (anchor path fails closed)");
288
+ return;
289
+ }
290
+ const connection = core.router.fromAnchor(cwd);
291
+ if (connection === void 0) return void 0;
292
+ const record = genericRecordFor(core, connection.workspaceId);
293
+ if (record === void 0) throw new Error(`fs-workspace: routed workspace '${connection.workspaceId}' is missing from the ledger`);
294
+ if (record.anchor === void 0) throw new Error(`fs-workspace: workspace '${record.id}' has no anchor`);
295
+ const backend = connection.get("workspace.fs");
296
+ if (backend === void 0) throw new Error(`fs-workspace: workspace '${record.id}' provides no workspace.fs capability`);
297
+ return {
298
+ backend,
299
+ namespace: `${WFS_NAMESPACE_MARKER}${record.id.toLowerCase()}/`,
300
+ anchorPath: record.anchor.path,
301
+ remoteRoot: record.location.root
302
+ };
303
+ }
304
+ /** Resolve one namespaced target key, failing closed for stale namespaces. */
305
+ function genericFsWorldForNamespace(core, namespace) {
306
+ if (!core.isReady()) return void 0;
307
+ const route = core.router.codec.decode(namespace);
308
+ if (route === void 0) return void 0;
309
+ const resolution = core.router.resolveRoute(route);
310
+ if (resolution === void 0) return void 0;
311
+ const record = genericRecordFor(core, resolution.connection.workspaceId);
312
+ if (record === void 0) throw new Error(`fs-workspace: routed workspace '${resolution.connection.workspaceId}' is missing from the ledger`);
313
+ if (record.anchor === void 0) throw new Error(`fs-workspace: workspace '${record.id}' has no anchor`);
314
+ const backend = resolution.connection.get("workspace.fs");
315
+ if (backend === void 0) throw new Error(`fs-workspace: workspace '${record.id}' provides no workspace.fs capability`);
316
+ return {
317
+ backend,
318
+ namespace: `${WFS_NAMESPACE_MARKER}${record.id.toLowerCase()}/`,
319
+ anchorPath: record.anchor.path,
320
+ remoteRoot: record.location.root
321
+ };
322
+ }
323
+ /** Mount the generic switching filesystem facade. */
447
324
  function apply(ctx) {
448
- const core = ctx.hardsshCore;
449
325
  const localCtx = ctx.isolate("fs");
450
326
  const localFs = new SandboxedFileSystem(localCtx, {
451
327
  cwd: process.env.DSH_CWD ?? process.cwd(),
452
328
  diffBasisMaxBytes: 10485760
453
329
  });
454
- core.seams.bindFs((record) => ({
455
- backend: new SshFileSystem(ctx.isolate("fs"), core.engine, fixedRemoteState(record.alias, record.remoteRoot)),
456
- namespace: `${WFS_NAMESPACE_MARKER}${record.id.toLowerCase()}/`,
457
- anchorPath: record.anchorPath,
458
- remoteRoot: record.remoteRoot
459
- }));
460
- const anchorRoot = core.ledger.anchorsRoot();
330
+ const ws = ctx.workspaceCore;
331
+ const anchorRootDir = anchorRoot();
461
332
  let warnedUnready = false;
462
333
  new SwitchFileSystem(ctx, {
463
334
  local: localFs,
335
+ localRoots: [join(homedir(), ".dsh"), join(homedir(), ".agents")],
336
+ localRootExclusions: [anchorRootDir],
337
+ deniedRoots: [vaultDirectory(), legacyVaultPath()],
338
+ worldForAnchorPath: (path) => {
339
+ const world = genericFsWorldFor(ws, path, [anchorRootDir]);
340
+ if (world !== void 0) return world;
341
+ if (isPathUnderAnchor(anchorRootDir, path)) throw new Error(`fs-ssh: '${path}' is inside the workspace anchor root but no registered workspace owns it (fail closed)`);
342
+ },
464
343
  worldFor: (cwd) => {
465
- if (!core.seams.isReady() && !warnedUnready && cwd !== void 0 && isPathUnderAnchor(anchorRoot, cwd)) {
466
- warnedUnready = true;
467
- console.warn("[dsh-hardssh] fs routing is not ready yet (workspace ledger still loading) — operating on the local anchor until the snapshot is applied");
344
+ if (!ws.isReady()) {
345
+ if (cwd !== void 0 && isPathUnderAnchor(anchorRootDir, cwd)) {
346
+ if (!warnedUnready) {
347
+ warnedUnready = true;
348
+ console.warn("[dsh-hardssh] fs routing is not ready yet (workspace core still initializing or failed) — refusing access beneath the workspace anchor root");
349
+ }
350
+ throw new Error("fs-ssh: workspace routing is unavailable while the generic workspace core is not ready (anchor path fails closed)");
351
+ }
352
+ return {
353
+ backend: localFs,
354
+ namespace: ""
355
+ };
468
356
  }
469
- return core.seams.worldForFs(cwd) ?? {
357
+ const world = genericFsWorldFor(ws, cwd, [anchorRootDir]);
358
+ if (world !== void 0) return world;
359
+ if (cwd !== void 0 && isPathUnderAnchor(anchorRootDir, cwd)) throw new Error(`fs-ssh: '${cwd}' is inside the workspace anchor root but no registered workspace owns it (fail closed)`);
360
+ return {
470
361
  backend: localFs,
471
362
  namespace: ""
472
363
  };
473
364
  },
474
- worldForNamespace: (namespace) => core.seams.worldForFsNamespace(namespace)
365
+ worldForNamespace: (namespace) => genericFsWorldForNamespace(ws, namespace)
475
366
  });
476
367
  }
477
368
  //#endregion
478
- export { apply, inject, name };
369
+ export { apply, genericFsWorldFor, genericFsWorldForNamespace, inject, name };