@tiphareth/dsh-hardssh 0.1.2 → 0.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +30 -0
- package/README.md +77 -50
- package/SKILLS.md +140 -0
- package/lib/base/index.js +95 -0
- package/lib/client.js +1171 -1321
- package/lib/fs.js +333 -416
- package/lib/index.js +5428 -2991
- package/lib/ledger-B-LXlftp.js +43 -0
- package/lib/ledger-D2ezq1iW.js +376 -0
- package/lib/model-Cp7f70Mb.js +5 -0
- package/lib/registry-CViuzKYI.js +421 -0
- package/lib/subprocess.js +92 -549
- package/lib/types/backend.d.ts +73 -78
- package/lib/types/base/capability.d.ts +4 -0
- package/lib/types/base/ledger-router.d.ts +42 -26
- package/lib/types/base/ledger.d.ts +30 -19
- package/lib/types/base/model.d.ts +17 -8
- package/lib/types/base/plugin.d.ts +11 -0
- package/lib/types/base/registry.d.ts +28 -31
- package/lib/types/base/router.d.ts +0 -7
- package/lib/types/client/api.d.ts +8 -8
- package/lib/types/client/connect-host.d.ts +13 -0
- package/lib/types/client/index.d.ts +6 -4
- package/lib/types/client/locales.d.ts +15 -1
- package/lib/types/client/session-connect-gate.d.ts +71 -0
- package/lib/types/client/ssh/api.d.ts +11 -9
- package/lib/types/client/ssh/apply.d.ts +14 -8
- package/lib/types/client/ssh/locales.d.ts +8 -1
- package/lib/types/client/ssh/ops-tab.d.ts +34 -0
- package/lib/types/client/ssh/panel/ClusterTab.d.ts +3 -1
- package/lib/types/client/ssh/panel/ConnectionErrorDialog.d.ts +8 -0
- package/lib/types/client/ssh/panel/SessionSecretDialog.d.ts +4 -1
- package/lib/types/client/ssh/panel/SshPanel.d.ts +8 -8
- package/lib/types/client/ssh/panel/TerminalTab.d.ts +3 -5
- package/lib/types/client/ssh/panel/TransferTab.d.ts +5 -1
- package/lib/types/client/ssh/panel/TunnelsTab.d.ts +3 -1
- package/lib/types/client/ssh/session-target.d.ts +17 -0
- package/lib/types/client/state.d.ts +4 -1
- package/lib/types/client/workspace-panel-entry.d.ts +30 -0
- package/lib/types/client/workspace-panel.d.ts +24 -0
- package/lib/types/client-http.d.ts +77 -6
- package/lib/types/core.d.ts +5 -16
- package/lib/types/fs.d.ts +42 -25
- package/lib/types/index.d.ts +138 -9
- package/lib/types/ledger.d.ts +16 -111
- package/lib/types/protocol.d.ts +0 -5
- package/lib/types/providers/index.d.ts +6 -3
- package/lib/types/providers/local/provider.d.ts +83 -23
- package/lib/types/providers/ssh/provider.d.ts +24 -57
- package/lib/types/remote/environment.d.ts +2 -0
- package/lib/types/remote/remote-fs.d.ts +12 -1
- package/lib/types/remote/remote-process.d.ts +22 -1
- package/lib/types/remote/remote-subprocess.d.ts +13 -1
- package/lib/types/remote/remote-terminal.d.ts +4 -0
- package/lib/types/remote-search.d.ts +3 -3
- package/lib/types/routes.d.ts +36 -8
- package/lib/types/runtime/dsh-capabilities.d.ts +18 -0
- package/lib/types/runtime/workspace-core.d.ts +73 -38
- package/lib/types/runtime/workspace-migration.d.ts +113 -0
- package/lib/types/ssh/connection/lease.d.ts +23 -0
- package/lib/types/ssh/connection/manager.d.ts +359 -0
- package/lib/types/ssh/connection/pool.d.ts +8 -1
- package/lib/types/ssh/engine.d.ts +97 -267
- package/lib/types/ssh/known-hosts.d.ts +22 -5
- package/lib/types/ssh/local-transfer-policy.d.ts +20 -0
- package/lib/types/ssh/plugin.d.ts +10 -15
- package/lib/types/ssh/protocol.d.ts +18 -0
- package/lib/types/ssh/routes.d.ts +15 -7
- package/lib/types/ssh/sftp/service.d.ts +216 -0
- package/lib/types/ssh/store.d.ts +20 -8
- package/lib/types/ssh/terminal/service.d.ts +49 -0
- package/lib/types/ssh/tunnel/service.d.ts +26 -0
- package/lib/types/ssh/vault.d.ts +51 -4
- package/lib/types/subprocess.d.ts +13 -21
- package/lib/types/switch/switch-fs.d.ts +52 -2
- package/lib/types/switch/switch-subprocess.d.ts +32 -3
- package/lib/types/tools.d.ts +7 -6
- package/lib/types/workspace-tool-ops.d.ts +33 -0
- package/lib/types/workspace.d.ts +18 -0
- package/lib/vault-3gpWct2Q.js +559 -0
- package/lib/workspace.js +2 -0
- package/package.json +59 -26
- package/scripts/export-legacy-workspaces.mjs +136 -0
- package/src/backend.ts +155 -483
- package/src/base/capability.ts +4 -0
- package/src/base/ledger-router.ts +214 -65
- package/src/base/ledger.ts +175 -48
- package/src/base/model.ts +18 -8
- package/src/base/plugin.ts +15 -4
- package/src/base/registry.ts +49 -41
- package/src/base/router.ts +0 -8
- package/src/client/api.ts +8 -36
- package/src/client/connect-host.ts +199 -0
- package/src/client/directory-flow.tsx +91 -36
- package/src/client/index.ts +56 -53
- package/src/client/locales.ts +14 -0
- package/src/client/session-connect-gate.ts +163 -0
- package/src/client/ssh/api.ts +245 -108
- package/src/client/ssh/apply.ts +23 -24
- package/src/client/ssh/locales.ts +16 -2
- package/src/client/ssh/ops-tab.tsx +79 -0
- package/src/client/ssh/panel/ClusterTab.tsx +9 -20
- package/src/client/ssh/panel/ConnectionErrorDialog.tsx +33 -0
- package/src/client/ssh/panel/SessionSecretDialog.tsx +5 -1
- package/src/client/ssh/panel/SshPanel.tsx +81 -77
- package/src/client/ssh/panel/TerminalTab.tsx +18 -37
- package/src/client/ssh/panel/TransferTab.tsx +43 -33
- package/src/client/ssh/panel/TunnelsTab.tsx +9 -26
- package/src/client/ssh/panel/panel.module.css +90 -127
- package/src/client/ssh/session-target.ts +69 -0
- package/src/client/state.ts +9 -1
- package/src/client/workspace-badges.ts +99 -96
- package/src/client/workspace-panel-entry.tsx +91 -0
- package/src/client/workspace-panel.tsx +227 -0
- package/src/client/workspace.module.css +76 -2
- package/src/client-http.ts +118 -18
- package/src/core.ts +37 -47
- package/src/fs.ts +171 -85
- package/src/index.ts +535 -244
- package/src/ledger.ts +62 -416
- package/src/protocol.ts +0 -6
- package/src/providers/index.ts +8 -4
- package/src/providers/local/provider.ts +259 -87
- package/src/providers/ssh/provider.ts +158 -155
- package/src/remote/environment.ts +22 -1
- package/src/remote/remote-fs.ts +123 -34
- package/src/remote/remote-process.ts +265 -203
- package/src/remote/remote-subprocess.ts +61 -18
- package/src/remote/remote-terminal.ts +106 -21
- package/src/remote-search.ts +122 -26
- package/src/routes.ts +416 -395
- package/src/runtime/dsh-capabilities.ts +19 -0
- package/src/runtime/workspace-core.ts +171 -88
- package/src/runtime/workspace-migration.ts +472 -0
- package/src/ssh/connection/lease.ts +35 -0
- package/src/ssh/connection/manager.ts +1083 -0
- package/src/ssh/connection/pool.ts +341 -275
- package/src/ssh/engine.ts +269 -1477
- package/src/ssh/known-hosts.ts +42 -18
- package/src/ssh/local-transfer-policy.ts +83 -0
- package/src/ssh/plugin.ts +20 -19
- package/src/ssh/protocol.ts +22 -1
- package/src/ssh/routes.ts +419 -187
- package/src/ssh/sftp/service.ts +967 -0
- package/src/ssh/store.ts +169 -72
- package/src/ssh/terminal/service.ts +177 -0
- package/src/ssh/tools.ts +42 -20
- package/src/ssh/tunnel/service.ts +217 -0
- package/src/ssh/vault.ts +245 -91
- package/src/subprocess.ts +88 -71
- package/src/switch/switch-fs.ts +141 -10
- package/src/switch/switch-subprocess.ts +80 -6
- package/src/tools.ts +217 -221
- package/src/workspace-tool-ops.ts +101 -0
- package/src/workspace.ts +52 -0
- package/lib/environment-BL1jddfB.js +0 -449
- package/lib/switch-fs-CAJpFY9C.js +0 -193
- package/lib/switch-fs-RrZtG2gv.js +0 -210
- package/lib/types/client/manager-button.d.ts +0 -32
- package/lib/types/client/ssh/mount.d.ts +0 -13
- package/lib/types/client/ssh/panel/HostsTab.d.ts +0 -10
- package/lib/types/client/ssh/panel/controller.d.ts +0 -23
- package/lib/types/client/ssh/sidebar-entry.d.ts +0 -25
- package/lib/types/client/workspace-gate.d.ts +0 -15
- package/lib/types/remote-runner.d.ts +0 -83
- package/lib/types/seam-state.d.ts +0 -69
- package/src/client/manager-button.tsx +0 -269
- package/src/client/ssh/mount.tsx +0 -83
- package/src/client/ssh/panel/HostsTab.tsx +0 -236
- package/src/client/ssh/panel/controller.ts +0 -48
- package/src/client/ssh/sidebar-entry.ts +0 -123
- package/src/client/workspace-gate.ts +0 -225
- package/src/remote-runner.ts +0 -201
- package/src/seam-state.ts +0 -185
package/lib/fs.js
CHANGED
|
@@ -1,478 +1,395 @@
|
|
|
1
|
-
import { i as
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
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, FsError } from "@deepseek-ai/dsh-fs";
|
|
6
7
|
import { SandboxedFileSystem } from "@deepseek-ai/dsh-fs-sandbox";
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
-
|
|
25
|
-
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
throw new FsError(`cannot read "${displayPath}": invalid UTF-8 text`, "FS_NOT_TEXT", { 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;
|
|
41
46
|
}
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
throw new Error("fs-ssh: canonical path is not valid UTF-8", { cause: error });
|
|
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;
|
|
53
57
|
}
|
|
54
|
-
|
|
55
|
-
|
|
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;
|
|
58
|
+
/** Encode a raw key into the world's namespace. */
|
|
59
|
+
encode(rawKey, namespace) {
|
|
60
|
+
return namespace === "" ? rawKey : `${namespace}${rawKey}`;
|
|
90
61
|
}
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
123
|
-
|
|
92
|
+
rawKey: key,
|
|
93
|
+
world: {
|
|
94
|
+
backend: this.local,
|
|
95
|
+
namespace: ""
|
|
96
|
+
}
|
|
124
97
|
};
|
|
125
98
|
}
|
|
126
99
|
/**
|
|
127
|
-
*
|
|
128
|
-
*
|
|
129
|
-
* remote
|
|
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).
|
|
130
108
|
*/
|
|
131
|
-
|
|
132
|
-
if (
|
|
133
|
-
|
|
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.
|
|
118
|
+
*/
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
const
|
|
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:
|
|
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
|
-
|
|
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
|
|
155
|
-
if (
|
|
156
|
-
return
|
|
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
|
|
160
|
-
|
|
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
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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
|
|
246
|
-
if (
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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
|
-
|
|
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
|
-
});
|
|
253
|
+
}));
|
|
312
254
|
}
|
|
313
|
-
async
|
|
314
|
-
const
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
}
|
|
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);
|
|
322
263
|
}
|
|
323
|
-
async
|
|
324
|
-
const
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
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
|
-
}
|
|
346
|
-
}
|
|
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;
|
|
352
|
-
}
|
|
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
|
-
/**
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
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);
|
|
283
|
+
}
|
|
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
|
+
};
|
|
445
322
|
}
|
|
446
|
-
/**
|
|
323
|
+
/**
|
|
324
|
+
* Refuse a path that sits inside the managed anchor root but is owned by no
|
|
325
|
+
* registered workspace — as a NOT-FOUND answer, not as a fatal error.
|
|
326
|
+
*
|
|
327
|
+
* The path stays unreachable either way (it is never handed to the local
|
|
328
|
+
* backend, so a bound session cannot read or write client files through the
|
|
329
|
+
* anchor window). What changes is how callers SEE the refusal: `FS_NOT_FOUND`
|
|
330
|
+
* is the DSH contract for "this path does not exist", and the harness relies on
|
|
331
|
+
* it when walking UP from the session cwd looking for a project root —
|
|
332
|
+
* `dsh-agent-instructions` probes `<dir>/.git` per ancestor and treats ONLY
|
|
333
|
+
* `FS_NOT_FOUND` as "keep walking" (any other error aborts the whole run),
|
|
334
|
+
* `dsh-skill-filesystem` does the same. With a bare Error the very first step
|
|
335
|
+
* above the anchor (`<anchorRoot>/.git`) killed the run with
|
|
336
|
+
* "fs-ssh: … is inside the workspace anchor root but no registered workspace
|
|
337
|
+
* owns it (fail closed)". The message is kept verbatim so the refusal is still
|
|
338
|
+
* self-explanatory in logs.
|
|
339
|
+
*/
|
|
340
|
+
function refuseUnownedAnchorPath(path) {
|
|
341
|
+
throw new FsError(`fs-ssh: '${path}' is inside the workspace anchor root but no registered workspace owns it (fail closed)`, "FS_NOT_FOUND");
|
|
342
|
+
}
|
|
343
|
+
/**
|
|
344
|
+
* The shipped `worldForAnchorPath` deps hook: the workspace owning an absolute
|
|
345
|
+
* anchor path (this session's or a SIBLING's), else the anchor-window refusal.
|
|
346
|
+
* Exported so the seam can be exercised without re-implementing the policy.
|
|
347
|
+
*/
|
|
348
|
+
function anchorWorldFor(core, anchorRootDir, path) {
|
|
349
|
+
const world = genericFsWorldFor(core, path, [anchorRootDir]);
|
|
350
|
+
if (world !== void 0) return world;
|
|
351
|
+
if (isPathUnderAnchor(anchorRootDir, path)) refuseUnownedAnchorPath(path);
|
|
352
|
+
}
|
|
353
|
+
/** Mount the generic switching filesystem facade. */
|
|
447
354
|
function apply(ctx) {
|
|
448
|
-
const core = ctx.hardsshCore;
|
|
449
355
|
const localCtx = ctx.isolate("fs");
|
|
450
356
|
const localFs = new SandboxedFileSystem(localCtx, {
|
|
451
357
|
cwd: process.env.DSH_CWD ?? process.cwd(),
|
|
452
358
|
diffBasisMaxBytes: 10485760
|
|
453
359
|
});
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
namespace: `${WFS_NAMESPACE_MARKER}${record.id.toLowerCase()}/`,
|
|
457
|
-
anchorPath: record.anchorPath,
|
|
458
|
-
remoteRoot: record.remoteRoot
|
|
459
|
-
}));
|
|
460
|
-
const anchorRoot = core.ledger.anchorsRoot();
|
|
360
|
+
const ws = ctx.workspaceCore;
|
|
361
|
+
const anchorRootDir = anchorRoot();
|
|
461
362
|
let warnedUnready = false;
|
|
462
363
|
new SwitchFileSystem(ctx, {
|
|
463
364
|
local: localFs,
|
|
365
|
+
localRoots: [join(homedir(), ".dsh"), join(homedir(), ".agents")],
|
|
366
|
+
localRootExclusions: [anchorRootDir],
|
|
367
|
+
deniedRoots: [vaultDirectory(), legacyVaultPath()],
|
|
368
|
+
worldForAnchorPath: (path) => anchorWorldFor(ws, anchorRootDir, path),
|
|
464
369
|
worldFor: (cwd) => {
|
|
465
|
-
if (!
|
|
466
|
-
|
|
467
|
-
|
|
370
|
+
if (!ws.isReady()) {
|
|
371
|
+
if (cwd !== void 0 && isPathUnderAnchor(anchorRootDir, cwd)) {
|
|
372
|
+
if (!warnedUnready) {
|
|
373
|
+
warnedUnready = true;
|
|
374
|
+
console.warn("[dsh-hardssh] fs routing is not ready yet (workspace core still initializing or failed) — refusing access beneath the workspace anchor root");
|
|
375
|
+
}
|
|
376
|
+
throw new Error("fs-ssh: workspace routing is unavailable while the generic workspace core is not ready (anchor path fails closed)");
|
|
377
|
+
}
|
|
378
|
+
return {
|
|
379
|
+
backend: localFs,
|
|
380
|
+
namespace: ""
|
|
381
|
+
};
|
|
468
382
|
}
|
|
469
|
-
|
|
383
|
+
const world = genericFsWorldFor(ws, cwd, [anchorRootDir]);
|
|
384
|
+
if (world !== void 0) return world;
|
|
385
|
+
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)`);
|
|
386
|
+
return {
|
|
470
387
|
backend: localFs,
|
|
471
388
|
namespace: ""
|
|
472
389
|
};
|
|
473
390
|
},
|
|
474
|
-
worldForNamespace: (namespace) =>
|
|
391
|
+
worldForNamespace: (namespace) => genericFsWorldForNamespace(ws, namespace)
|
|
475
392
|
});
|
|
476
393
|
}
|
|
477
394
|
//#endregion
|
|
478
|
-
export { apply, inject, name };
|
|
395
|
+
export { anchorWorldFor, apply, genericFsWorldFor, genericFsWorldForNamespace, inject, name, refuseUnownedAnchorPath };
|