@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
|
@@ -1,449 +0,0 @@
|
|
|
1
|
-
import { randomUUID } from "node:crypto";
|
|
2
|
-
import { realpathSync } from "node:fs";
|
|
3
|
-
import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
4
|
-
import { homedir } from "node:os";
|
|
5
|
-
import { join, posix } from "node:path";
|
|
6
|
-
import { SENSITIVE_ENV_PATTERN } from "@deepseek-ai/dsh-subprocess";
|
|
7
|
-
//#region src/ledger.ts
|
|
8
|
-
/**
|
|
9
|
-
* SSH-bound workspace ledger: the mapping from a LOCAL ANCHOR directory (the
|
|
10
|
-
* host-visible workspace path, which sessions use as their cwd) to a REMOTE
|
|
11
|
-
* directory on an SSH host. Persisted as JSON next to dsh-ssh.json so the
|
|
12
|
-
* fs/subprocess seams can route a session whose cwd is an anchor to the
|
|
13
|
-
* remote execution world, and everything else stays local.
|
|
14
|
-
*
|
|
15
|
-
* A ledger record is created through the GUI (pick a host, browse a remote
|
|
16
|
-
* dir, give it a title). The host-side anchor directory is auto-created
|
|
17
|
-
* under ~/.dsh/ssh-workspaces/<id>/; the host workspace registry owns it as
|
|
18
|
-
* a normal local workspace, so session creation/listing/persistence keep
|
|
19
|
-
* working untouched.
|
|
20
|
-
*
|
|
21
|
-
* Mutations are serialized through a per-instance queue and committed as:
|
|
22
|
-
* write temp file → atomic rename → swap memory/index → bump revision →
|
|
23
|
-
* notify subscribers. A persistence failure leaves observable state
|
|
24
|
-
* unchanged and never publishes an event.
|
|
25
|
-
*
|
|
26
|
-
* @module dsh-hardssh/ledger
|
|
27
|
-
*/
|
|
28
|
-
/** Ledger file location: ~/.dsh/dsh-hardssh-workspaces.json. */
|
|
29
|
-
function ledgerPath() {
|
|
30
|
-
return join(homedir(), ".dsh", "dsh-hardssh-workspaces.json");
|
|
31
|
-
}
|
|
32
|
-
/**
|
|
33
|
-
* Normalize a remote POSIX root: keep '/' as '/', collapse repeated
|
|
34
|
-
* separators and dot segments, reject relative paths and NUL.
|
|
35
|
-
*/
|
|
36
|
-
function normalizeRemoteRoot(raw) {
|
|
37
|
-
if (raw.includes("\0")) throw new Error(`remoteRoot must not contain NUL (got '${raw}')`);
|
|
38
|
-
const normalized = posix.normalize(raw.trim());
|
|
39
|
-
if (!normalized.startsWith("/")) throw new Error(`remoteRoot must be an absolute POSIX path (got '${raw}')`);
|
|
40
|
-
return normalized === "/" ? "/" : normalized.replace(/\/+$/, "");
|
|
41
|
-
}
|
|
42
|
-
/** Anchor roots: ~/.dsh/ssh-workspaces/<id>/ — visible in the sidebar as
|
|
43
|
-
* ordinary host workspaces (and thus selectable for sessions). */
|
|
44
|
-
function anchorRoot() {
|
|
45
|
-
return join(homedir(), ".dsh", "ssh-workspaces");
|
|
46
|
-
}
|
|
47
|
-
/** Default record title when the user gives none. */
|
|
48
|
-
function defaultTitle(remoteRoot, alias) {
|
|
49
|
-
return `${alias}:${remoteRoot.split("/").filter(Boolean).pop() ?? remoteRoot}`;
|
|
50
|
-
}
|
|
51
|
-
/**
|
|
52
|
-
* Normalize a local anchor path for comparison: Windows anchors are
|
|
53
|
-
* case-insensitive with mixed separators; POSIX anchors stay case-sensitive.
|
|
54
|
-
*/
|
|
55
|
-
function normalizeAnchorPath(path) {
|
|
56
|
-
if (isWindowsAnchor(path)) {
|
|
57
|
-
const normalized = path.replace(/\//g, "\\");
|
|
58
|
-
return trimTrailingSeparators(normalized, windowsRootLength(normalized)).toLowerCase();
|
|
59
|
-
}
|
|
60
|
-
return trimTrailingSeparators(path, path.startsWith("/") ? 1 : 0);
|
|
61
|
-
}
|
|
62
|
-
/** True when candidate equals anchor or is one of its descendants. Lexical
|
|
63
|
-
* comparison over already-resolved paths; deliberately no fs access. */
|
|
64
|
-
function isPathUnderAnchor(anchor, candidate) {
|
|
65
|
-
const windowsStyle = isWindowsAnchor(anchor);
|
|
66
|
-
const normalizedAnchor = normalizeAnchorPath(anchor);
|
|
67
|
-
let normalizedCandidate;
|
|
68
|
-
if (windowsStyle) {
|
|
69
|
-
const withWindowsSeparators = candidate.replace(/\//g, "\\");
|
|
70
|
-
normalizedCandidate = trimTrailingSeparators(withWindowsSeparators, windowsRootLength(withWindowsSeparators)).toLowerCase();
|
|
71
|
-
} else normalizedCandidate = trimTrailingSeparators(candidate, candidate.startsWith("/") ? 1 : 0);
|
|
72
|
-
if (normalizedCandidate === normalizedAnchor) return true;
|
|
73
|
-
const separator = windowsStyle ? "\\" : "/";
|
|
74
|
-
const descendantPrefix = normalizedAnchor.endsWith(separator) ? normalizedAnchor : `${normalizedAnchor}${separator}`;
|
|
75
|
-
return normalizedCandidate.startsWith(descendantPrefix);
|
|
76
|
-
}
|
|
77
|
-
/**
|
|
78
|
-
* The in-memory ledger. Loaded lazily on first access; every mutation writes
|
|
79
|
-
* through durably (atomic rename) and is serialized through a per-instance
|
|
80
|
-
* queue. A synchronous anchor index is maintained from the latest committed
|
|
81
|
-
* records so host seams can resolve a cwd/root without awaiting I/O.
|
|
82
|
-
*/
|
|
83
|
-
var SshWorkspaceLedger = class {
|
|
84
|
-
fileOverride;
|
|
85
|
-
anchorOverride;
|
|
86
|
-
records;
|
|
87
|
-
loadPromise;
|
|
88
|
-
anchorIndex = [];
|
|
89
|
-
mutationTail = Promise.resolve();
|
|
90
|
-
currentRevision = 0;
|
|
91
|
-
listeners = /* @__PURE__ */ new Set();
|
|
92
|
-
/** Overridable file location (tests isolate per instance). */
|
|
93
|
-
constructor(fileOverride, anchorOverride) {
|
|
94
|
-
this.fileOverride = fileOverride;
|
|
95
|
-
this.anchorOverride = anchorOverride;
|
|
96
|
-
}
|
|
97
|
-
file() {
|
|
98
|
-
return this.fileOverride ?? ledgerPath();
|
|
99
|
-
}
|
|
100
|
-
anchors() {
|
|
101
|
-
return this.anchorOverride ?? anchorRoot();
|
|
102
|
-
}
|
|
103
|
-
anchorFor(id) {
|
|
104
|
-
return join(this.anchors(), id);
|
|
105
|
-
}
|
|
106
|
-
/** Load the ledger once (missing/unreadable/malformed file -> empty ledger). */
|
|
107
|
-
async ensureLoaded() {
|
|
108
|
-
if (this.records !== void 0) return;
|
|
109
|
-
if (this.loadPromise === void 0) this.loadPromise = this.readRecords();
|
|
110
|
-
await this.loadPromise;
|
|
111
|
-
}
|
|
112
|
-
async readRecords() {
|
|
113
|
-
try {
|
|
114
|
-
const text = await readFile(this.file(), "utf8");
|
|
115
|
-
const parsed = JSON.parse(text);
|
|
116
|
-
this.records = Array.isArray(parsed) ? parsed.filter(isRecord).map(cloneRecord) : [];
|
|
117
|
-
} catch {
|
|
118
|
-
this.records = [];
|
|
119
|
-
}
|
|
120
|
-
this.reindex();
|
|
121
|
-
}
|
|
122
|
-
/** Rebuild the synchronous anchor index from the current records. */
|
|
123
|
-
reindex() {
|
|
124
|
-
this.anchorIndex = (this.records ?? []).map((record) => ({
|
|
125
|
-
anchor: normalizeAnchorPath(record.anchorPath),
|
|
126
|
-
record
|
|
127
|
-
})).sort((a, b) => b.anchor.length - a.anchor.length);
|
|
128
|
-
}
|
|
129
|
-
/** Persist a proposed record array without touching observable state. */
|
|
130
|
-
async save(nextRecords) {
|
|
131
|
-
const target = this.file();
|
|
132
|
-
await mkdir(join(target, ".."), { recursive: true });
|
|
133
|
-
const temporary = `${target}.tmp-${process.pid}-${Date.now()}-${randomUUID()}`;
|
|
134
|
-
try {
|
|
135
|
-
await writeFile(temporary, JSON.stringify(nextRecords, null, 2), "utf8");
|
|
136
|
-
await rename(temporary, target);
|
|
137
|
-
} catch (error) {
|
|
138
|
-
await rm(temporary, { force: true }).catch(() => void 0);
|
|
139
|
-
throw error;
|
|
140
|
-
}
|
|
141
|
-
}
|
|
142
|
-
/** Serialize one mutation; a rejected earlier mutation never poisons later ones. */
|
|
143
|
-
enqueueMutation(operation) {
|
|
144
|
-
const result = this.mutationTail.then(operation);
|
|
145
|
-
this.mutationTail = result.then(() => void 0, () => void 0);
|
|
146
|
-
return result;
|
|
147
|
-
}
|
|
148
|
-
/** Commit a computed next state: swap memory, bump revision, notify. */
|
|
149
|
-
commit(nextRecords, change) {
|
|
150
|
-
this.records = nextRecords;
|
|
151
|
-
this.reindex();
|
|
152
|
-
this.currentRevision += 1;
|
|
153
|
-
this.emit({
|
|
154
|
-
...change,
|
|
155
|
-
revision: this.currentRevision
|
|
156
|
-
});
|
|
157
|
-
}
|
|
158
|
-
emit(change) {
|
|
159
|
-
for (const listener of [...this.listeners]) try {
|
|
160
|
-
listener(cloneChange(change));
|
|
161
|
-
} catch (error) {
|
|
162
|
-
console.warn("[dsh-hardssh] ledger subscriber failed:", error instanceof Error ? error.message : String(error));
|
|
163
|
-
}
|
|
164
|
-
}
|
|
165
|
-
/** Load the ledger (compat; returns detached copies). */
|
|
166
|
-
async load() {
|
|
167
|
-
await this.ensureLoaded();
|
|
168
|
-
return cloneRecords(this.records ?? []);
|
|
169
|
-
}
|
|
170
|
-
/** All records, in creation order (detached copies). */
|
|
171
|
-
async list() {
|
|
172
|
-
await this.ensureLoaded();
|
|
173
|
-
return cloneRecords(this.records ?? []);
|
|
174
|
-
}
|
|
175
|
-
/** Current detached snapshot. */
|
|
176
|
-
async snapshot() {
|
|
177
|
-
await this.ensureLoaded();
|
|
178
|
-
return {
|
|
179
|
-
revision: this.currentRevision,
|
|
180
|
-
records: cloneRecords(this.records ?? [])
|
|
181
|
-
};
|
|
182
|
-
}
|
|
183
|
-
/** Current in-process snapshot, synchronously (empty before first load).
|
|
184
|
-
* The returned records are the live immutable objects — callers must not
|
|
185
|
-
* mutate them (commits always replace the array wholesale). */
|
|
186
|
-
snapshotSync() {
|
|
187
|
-
return {
|
|
188
|
-
revision: this.currentRevision,
|
|
189
|
-
records: this.records ?? []
|
|
190
|
-
};
|
|
191
|
-
}
|
|
192
|
-
/** Current in-process revision (restarts at 0 on process restart). */
|
|
193
|
-
revision() {
|
|
194
|
-
return this.currentRevision;
|
|
195
|
-
}
|
|
196
|
-
/** Subscribe to committed mutations (no initial event). Returns a disposer. */
|
|
197
|
-
subscribe(listener) {
|
|
198
|
-
this.listeners.add(listener);
|
|
199
|
-
let disposed = false;
|
|
200
|
-
return () => {
|
|
201
|
-
if (disposed) return;
|
|
202
|
-
disposed = true;
|
|
203
|
-
this.listeners.delete(listener);
|
|
204
|
-
};
|
|
205
|
-
}
|
|
206
|
-
/** Look up by id. */
|
|
207
|
-
async get(id) {
|
|
208
|
-
await this.ensureLoaded();
|
|
209
|
-
const record = this.records?.find((candidate) => candidate.id === id);
|
|
210
|
-
return record === void 0 ? void 0 : cloneRecord(record);
|
|
211
|
-
}
|
|
212
|
-
/** Look up by LOCAL anchor path (resolved). Returns the record whose
|
|
213
|
-
* anchor owns the path (the anchor itself or any descendant). */
|
|
214
|
-
async findByAnchor(path) {
|
|
215
|
-
await this.ensureLoaded();
|
|
216
|
-
return this.findByAnchorSync(path);
|
|
217
|
-
}
|
|
218
|
-
/** Synchronous anchor lookup (uses the in-memory index). */
|
|
219
|
-
findByAnchorSync(path) {
|
|
220
|
-
const canonical = safeRealpathSync(path);
|
|
221
|
-
if (canonical === void 0) return void 0;
|
|
222
|
-
for (const { anchor, record } of this.anchorIndex) if (isPathUnderAnchor(anchor, canonical)) return cloneRecord(record);
|
|
223
|
-
}
|
|
224
|
-
/** Create a record: materialize the anchor, then persist+commit. */
|
|
225
|
-
async create(input) {
|
|
226
|
-
return this.enqueueMutation(async () => {
|
|
227
|
-
await this.ensureLoaded();
|
|
228
|
-
const id = randomUUID();
|
|
229
|
-
const remoteRoot = normalizeRemoteRoot(input.remoteRoot);
|
|
230
|
-
const record = {
|
|
231
|
-
id,
|
|
232
|
-
title: input.title.trim() === "" ? defaultTitle(remoteRoot, input.alias) : input.title.trim(),
|
|
233
|
-
alias: input.alias,
|
|
234
|
-
remoteRoot,
|
|
235
|
-
anchorPath: this.anchorFor(id),
|
|
236
|
-
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
237
|
-
};
|
|
238
|
-
await mkdir(record.anchorPath, { recursive: true });
|
|
239
|
-
const nextRecords = [...this.records ?? [], record];
|
|
240
|
-
await this.save(nextRecords);
|
|
241
|
-
this.commit(nextRecords, {
|
|
242
|
-
type: "created",
|
|
243
|
-
record
|
|
244
|
-
});
|
|
245
|
-
return cloneRecord(record);
|
|
246
|
-
});
|
|
247
|
-
}
|
|
248
|
-
/** Rename a record (display title only). */
|
|
249
|
-
async rename(id, title) {
|
|
250
|
-
return this.enqueueMutation(async () => {
|
|
251
|
-
await this.ensureLoaded();
|
|
252
|
-
const records = this.records ?? [];
|
|
253
|
-
const index = records.findIndex((record) => record.id === id);
|
|
254
|
-
if (index < 0) return void 0;
|
|
255
|
-
const before = records[index];
|
|
256
|
-
const record = {
|
|
257
|
-
...before,
|
|
258
|
-
title: title.trim()
|
|
259
|
-
};
|
|
260
|
-
const nextRecords = [...records];
|
|
261
|
-
nextRecords[index] = record;
|
|
262
|
-
await this.save(nextRecords);
|
|
263
|
-
this.commit(nextRecords, {
|
|
264
|
-
type: "renamed",
|
|
265
|
-
before,
|
|
266
|
-
record
|
|
267
|
-
});
|
|
268
|
-
return cloneRecord(record);
|
|
269
|
-
});
|
|
270
|
-
}
|
|
271
|
-
/** Remove a record. The anchor directory is left in place (a host
|
|
272
|
-
* workspace may still reference it; the caller decides on deletion). */
|
|
273
|
-
async remove(id) {
|
|
274
|
-
return this.enqueueMutation(async () => {
|
|
275
|
-
await this.ensureLoaded();
|
|
276
|
-
const records = this.records ?? [];
|
|
277
|
-
const index = records.findIndex((record) => record.id === id);
|
|
278
|
-
if (index < 0) return false;
|
|
279
|
-
const record = records[index];
|
|
280
|
-
const nextRecords = [...records.slice(0, index), ...records.slice(index + 1)];
|
|
281
|
-
await this.save(nextRecords);
|
|
282
|
-
this.commit(nextRecords, {
|
|
283
|
-
type: "removed",
|
|
284
|
-
record
|
|
285
|
-
});
|
|
286
|
-
return true;
|
|
287
|
-
});
|
|
288
|
-
}
|
|
289
|
-
/** The anchor root shared by every record (for UI hints). */
|
|
290
|
-
anchorsRoot() {
|
|
291
|
-
return this.anchors();
|
|
292
|
-
}
|
|
293
|
-
};
|
|
294
|
-
/** Guard: a parsed JSON value is a valid record. */
|
|
295
|
-
function isRecord(value) {
|
|
296
|
-
if (typeof value !== "object" || value === null) return false;
|
|
297
|
-
const record = value;
|
|
298
|
-
return typeof record.id === "string" && typeof record.title === "string" && typeof record.alias === "string" && typeof record.remoteRoot === "string" && typeof record.anchorPath === "string";
|
|
299
|
-
}
|
|
300
|
-
/** Synchronous realpath, swallowing errors (a session cwd can vanish mid-flight). */
|
|
301
|
-
function safeRealpathSync(path) {
|
|
302
|
-
try {
|
|
303
|
-
return realpathSync(path);
|
|
304
|
-
} catch {
|
|
305
|
-
return;
|
|
306
|
-
}
|
|
307
|
-
}
|
|
308
|
-
function isWindowsAnchor(path) {
|
|
309
|
-
return /^[a-zA-Z]:[\\/]/.test(path) || path.startsWith("\\\\") || path.includes("\\");
|
|
310
|
-
}
|
|
311
|
-
function windowsRootLength(path) {
|
|
312
|
-
if (/^[a-zA-Z]:\\/.test(path)) return 3;
|
|
313
|
-
if (path.startsWith("\\\\")) {
|
|
314
|
-
const segments = path.slice(2).split("\\");
|
|
315
|
-
if (segments.length >= 2 && segments[0] !== "" && segments[1] !== "") return 2 + segments[0].length + 1 + segments[1].length;
|
|
316
|
-
return 2;
|
|
317
|
-
}
|
|
318
|
-
return 0;
|
|
319
|
-
}
|
|
320
|
-
function trimTrailingSeparators(path, minimumLength) {
|
|
321
|
-
let end = path.length;
|
|
322
|
-
while (end > minimumLength && (path[end - 1] === "/" || path[end - 1] === "\\")) end -= 1;
|
|
323
|
-
return path.slice(0, end);
|
|
324
|
-
}
|
|
325
|
-
function cloneRecord(record) {
|
|
326
|
-
return { ...record };
|
|
327
|
-
}
|
|
328
|
-
function cloneRecords(records) {
|
|
329
|
-
return records.map(cloneRecord);
|
|
330
|
-
}
|
|
331
|
-
function cloneChange(change) {
|
|
332
|
-
switch (change.type) {
|
|
333
|
-
case "created": return {
|
|
334
|
-
...change,
|
|
335
|
-
record: cloneRecord(change.record)
|
|
336
|
-
};
|
|
337
|
-
case "renamed": return {
|
|
338
|
-
...change,
|
|
339
|
-
before: cloneRecord(change.before),
|
|
340
|
-
record: cloneRecord(change.record)
|
|
341
|
-
};
|
|
342
|
-
case "removed": return {
|
|
343
|
-
...change,
|
|
344
|
-
record: cloneRecord(change.record)
|
|
345
|
-
};
|
|
346
|
-
}
|
|
347
|
-
}
|
|
348
|
-
//#endregion
|
|
349
|
-
//#region src/shell.ts
|
|
350
|
-
/**
|
|
351
|
-
* Canonical POSIX shell single-quoting for remote command construction.
|
|
352
|
-
* Replaces the three near-identical copies that used to live in backend.ts,
|
|
353
|
-
* tools.ts and remote/environment.ts.
|
|
354
|
-
*/
|
|
355
|
-
/** Quote one argument for a POSIX login shell (single-quote, `'\''` for embedded quotes). */
|
|
356
|
-
function shellQuote(value) {
|
|
357
|
-
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
358
|
-
}
|
|
359
|
-
//#endregion
|
|
360
|
-
//#region src/remote/environment.ts
|
|
361
|
-
/**
|
|
362
|
-
* Remote-environment scrubbing and caching for the SSH process and terminal
|
|
363
|
-
* launchers. Ported from UynajGI/dsh-ssh (MIT) — adapted to the dsh-ssh
|
|
364
|
-
* engine. The read side is cached per (engine, alias) with a short TTL and
|
|
365
|
-
* merged concurrent requests, so repeated spawns/terminals on one host stop
|
|
366
|
-
* issuing an `env` exec each time (P1-12).
|
|
367
|
-
*/
|
|
368
|
-
/** How long a scrubbed environment stays fresh per alias. */
|
|
369
|
-
const ENVIRONMENT_TTL_MS = 3e4;
|
|
370
|
-
/** Per-engine alias caches (WeakMap so engines are collected with the pool). */
|
|
371
|
-
const caches = /* @__PURE__ */ new WeakMap();
|
|
372
|
-
function cacheFor(engine) {
|
|
373
|
-
let byAlias = caches.get(engine);
|
|
374
|
-
if (byAlias === void 0) {
|
|
375
|
-
byAlias = /* @__PURE__ */ new Map();
|
|
376
|
-
caches.set(engine, byAlias);
|
|
377
|
-
}
|
|
378
|
-
return byAlias;
|
|
379
|
-
}
|
|
380
|
-
/**
|
|
381
|
-
* Read + scrub the remote login environment, cached per (engine, alias).
|
|
382
|
-
* Concurrent requests share one fetch; failures are never cached.
|
|
383
|
-
*/
|
|
384
|
-
function readScrubbedRemoteEnvironment(engine, alias) {
|
|
385
|
-
const byAlias = cacheFor(engine);
|
|
386
|
-
const now = Date.now();
|
|
387
|
-
const existing = byAlias.get(alias);
|
|
388
|
-
if (existing !== void 0 && existing.expiresAt > now && existing.value !== void 0) return Promise.resolve(existing.value);
|
|
389
|
-
if (existing?.pending !== void 0) return existing.pending;
|
|
390
|
-
const pending = fetchScrubbedEnvironment(engine, alias).then((value) => {
|
|
391
|
-
byAlias.set(alias, {
|
|
392
|
-
expiresAt: Date.now() + ENVIRONMENT_TTL_MS,
|
|
393
|
-
value
|
|
394
|
-
});
|
|
395
|
-
return value;
|
|
396
|
-
}, (error) => {
|
|
397
|
-
byAlias.delete(alias);
|
|
398
|
-
throw error;
|
|
399
|
-
});
|
|
400
|
-
byAlias.set(alias, {
|
|
401
|
-
expiresAt: 0,
|
|
402
|
-
pending
|
|
403
|
-
});
|
|
404
|
-
return pending;
|
|
405
|
-
}
|
|
406
|
-
/** Drop cached environments for one alias (call after a host config change). */
|
|
407
|
-
function invalidateRemoteEnvironment(engine, alias) {
|
|
408
|
-
cacheFor(engine).delete(alias);
|
|
409
|
-
}
|
|
410
|
-
/** One `env -0` round-trip, scrubbed (NUL-delimited so values keep newlines/=). */
|
|
411
|
-
async function fetchScrubbedEnvironment(engine, alias) {
|
|
412
|
-
const result = await engine.exec(alias, "env -0", 1e4);
|
|
413
|
-
if (!result.success) throw new Error(`subprocess-ssh: cannot read the remote environment: ${result.stderr.trim() || "unknown error"}`);
|
|
414
|
-
const environment = /* @__PURE__ */ new Map();
|
|
415
|
-
for (const record of result.stdout.split("\0")) {
|
|
416
|
-
if (record === "") continue;
|
|
417
|
-
const separator = record.indexOf("=");
|
|
418
|
-
if (separator <= 0) continue;
|
|
419
|
-
const name = record.slice(0, separator);
|
|
420
|
-
if (name.includes("\0")) continue;
|
|
421
|
-
environment.set(name, record.slice(separator + 1));
|
|
422
|
-
}
|
|
423
|
-
return scrubRemoteEnvironment(environment);
|
|
424
|
-
}
|
|
425
|
-
/**
|
|
426
|
-
* Remove harness-private and credential-shaped names from a remote environment.
|
|
427
|
-
*/
|
|
428
|
-
function scrubRemoteEnvironment(environment) {
|
|
429
|
-
const scrubbed = /* @__PURE__ */ new Map();
|
|
430
|
-
for (const [name, value] of environment) {
|
|
431
|
-
if (name.startsWith("DSH_") || SENSITIVE_ENV_PATTERN.test(name)) continue;
|
|
432
|
-
scrubbed.set(name, value);
|
|
433
|
-
}
|
|
434
|
-
return scrubbed;
|
|
435
|
-
}
|
|
436
|
-
/**
|
|
437
|
-
* Overlay explicit entries and serialize one validated environment for `env -i`.
|
|
438
|
-
*/
|
|
439
|
-
function serializeEnvironment(scrubbed, explicit) {
|
|
440
|
-
const environment = new Map(scrubbed);
|
|
441
|
-
for (const [name, value] of Object.entries(explicit ?? {})) {
|
|
442
|
-
if (name.length === 0 || name.includes("=") || name.includes("\0") || value?.includes("\0") === true) throw new Error("subprocess-ssh: environment entries require non-empty NUL-free names without = and NUL-free values");
|
|
443
|
-
if (value === void 0) environment.delete(name);
|
|
444
|
-
else environment.set(name, value);
|
|
445
|
-
}
|
|
446
|
-
return [...environment].map(([name, value]) => shellQuote(`${name}=${value}`)).join(" ");
|
|
447
|
-
}
|
|
448
|
-
//#endregion
|
|
449
|
-
export { SshWorkspaceLedger as a, shellQuote as i, readScrubbedRemoteEnvironment as n, isPathUnderAnchor as o, serializeEnvironment as r, normalizeAnchorPath as s, invalidateRemoteEnvironment as t };
|
|
@@ -1,193 +0,0 @@
|
|
|
1
|
-
import { FileSystem } from "@deepseek-ai/dsh-fs";
|
|
2
|
-
//#region src/switch/switch-fs.ts
|
|
3
|
-
/**
|
|
4
|
-
* The `ctx.fs` switching facade: routes every filesystem call to the backend
|
|
5
|
-
* of the SESSION's workspace — a local session operates on the local sandbox,
|
|
6
|
-
* an SSH-bound workspace on the remote host the workspace is bound to.
|
|
7
|
-
*
|
|
8
|
-
* Routing anchor: the cwd the tool call was made under (`resolve`'s
|
|
9
|
-
* `opts.cwd`, the write/edit `sandboxPolicy.workspaceRoot`). The resolver
|
|
10
|
-
* ("which SSH workspace owns this cwd?") lives in the deps; a hit routes to
|
|
11
|
-
* that workspace's remote backend, a miss to the local backend. Later
|
|
12
|
-
* operations (stat/read/write) receive only the resolved `FsTarget`, so the
|
|
13
|
-
* backend identity is encoded into the target key — `ssh:<recordId>:<remoteKey>`
|
|
14
|
-
* for remote targets, bare local keys for local ones — and decoded on every
|
|
15
|
-
* dispatch. This keeps two SSH workspaces on DIFFERENT hosts (or different
|
|
16
|
-
* directories on the SAME host) from colliding, and never misroutes a local
|
|
17
|
-
* target.
|
|
18
|
-
*
|
|
19
|
-
* @module dsh-hardssh/switch-fs
|
|
20
|
-
*/
|
|
21
|
-
/** Key namespace: 'ssh:<recordId>:' for remote workspaces. */
|
|
22
|
-
const REMOTE_PREFIX = "ssh:";
|
|
23
|
-
/** The routing filesystem facade. */
|
|
24
|
-
var SwitchFileSystem = class extends FileSystem {
|
|
25
|
-
deps;
|
|
26
|
-
local;
|
|
27
|
-
constructor(ctx, deps) {
|
|
28
|
-
super(ctx);
|
|
29
|
-
this.deps = deps;
|
|
30
|
-
this.local = deps.local;
|
|
31
|
-
}
|
|
32
|
-
/** Sandbox default from the local backend (remote worlds are unconfined —
|
|
33
|
-
* the docs/doc of the tool layer reads this once at mount). */
|
|
34
|
-
get sandboxMode() {
|
|
35
|
-
return this.local.sandboxMode;
|
|
36
|
-
}
|
|
37
|
-
/** Encode a raw key into the world's namespace. */
|
|
38
|
-
encode(rawKey, namespace) {
|
|
39
|
-
return namespace === "" ? rawKey : `${namespace}${rawKey}`;
|
|
40
|
-
}
|
|
41
|
-
/** Decode a namespaced key back to (rawKey, world). */
|
|
42
|
-
decode(key) {
|
|
43
|
-
if (key.startsWith("ssh:")) {
|
|
44
|
-
const end = key.indexOf(":", 4);
|
|
45
|
-
if (end > 0) {
|
|
46
|
-
const namespace = key.slice(0, end + 1);
|
|
47
|
-
const world = this.deps.worldForNamespace(namespace);
|
|
48
|
-
if (world !== void 0) return {
|
|
49
|
-
rawKey: key.slice(namespace.length),
|
|
50
|
-
world
|
|
51
|
-
};
|
|
52
|
-
}
|
|
53
|
-
return;
|
|
54
|
-
}
|
|
55
|
-
return {
|
|
56
|
-
rawKey: key,
|
|
57
|
-
world: {
|
|
58
|
-
backend: this.local,
|
|
59
|
-
namespace: ""
|
|
60
|
-
}
|
|
61
|
-
};
|
|
62
|
-
}
|
|
63
|
-
async resolve(path, opts) {
|
|
64
|
-
const world = this.deps.worldFor(opts?.cwd);
|
|
65
|
-
const effectivePath = world.anchorPath !== void 0 && world.remoteRoot !== void 0 ? this.translateAnchorPath(world.anchorPath, world.remoteRoot, path) : path;
|
|
66
|
-
const raw = await world.backend.resolve(effectivePath, opts);
|
|
67
|
-
return {
|
|
68
|
-
targetKey: this.encode(String(raw.targetKey), world.namespace),
|
|
69
|
-
displayPath: raw.displayPath
|
|
70
|
-
};
|
|
71
|
-
}
|
|
72
|
-
/** Rewrite `<anchor>/<rest>` to `<remoteRoot>/<rest>` when `path` is the
|
|
73
|
-
* anchor or under it; otherwise return `path` unchanged (remote absolute
|
|
74
|
-
* paths, relative paths, and foreign local paths all pass through). */
|
|
75
|
-
translateAnchorPath(anchor, remoteRoot, path) {
|
|
76
|
-
const norm = (value) => value.replace(/[\\/]+$/, "");
|
|
77
|
-
const a = norm(anchor);
|
|
78
|
-
const isWin = a.includes("\\");
|
|
79
|
-
const normPath = (value) => {
|
|
80
|
-
let out = norm(value);
|
|
81
|
-
if (isWin) out = out.replace(/\//g, "\\");
|
|
82
|
-
return out;
|
|
83
|
-
};
|
|
84
|
-
const p = normPath(path);
|
|
85
|
-
const aa = isWin ? a.toLowerCase() : a;
|
|
86
|
-
const pp = isWin ? p.toLowerCase() : p;
|
|
87
|
-
if (pp === aa) return remoteRoot;
|
|
88
|
-
if (pp.startsWith(`${aa}\\`) || pp.startsWith(`${aa}/`)) {
|
|
89
|
-
const tail = p.slice(a.length).replace(/^[\\/]+/, "").replace(/\\/g, "/");
|
|
90
|
-
return tail === "" ? remoteRoot : `${remoteRoot.replace(/\/+$/, "")}/${tail}`;
|
|
91
|
-
}
|
|
92
|
-
return path;
|
|
93
|
-
}
|
|
94
|
-
processPath(target) {
|
|
95
|
-
const key = String(target.targetKey);
|
|
96
|
-
const decoded = this.decode(key);
|
|
97
|
-
return decoded?.world.backend.processPath({
|
|
98
|
-
targetKey: decoded.rawKey,
|
|
99
|
-
displayPath: target.displayPath
|
|
100
|
-
}) ?? key;
|
|
101
|
-
}
|
|
102
|
-
fileUrl(target) {
|
|
103
|
-
const decoded = this.decode(String(target.targetKey));
|
|
104
|
-
if (decoded === void 0) return this.local.fileUrl(target);
|
|
105
|
-
return decoded.world.backend.fileUrl({
|
|
106
|
-
targetKey: decoded.rawKey,
|
|
107
|
-
displayPath: target.displayPath
|
|
108
|
-
});
|
|
109
|
-
}
|
|
110
|
-
contains(parent, child) {
|
|
111
|
-
const p = this.decode(String(parent.targetKey));
|
|
112
|
-
const c = this.decode(String(child.targetKey));
|
|
113
|
-
if (p === void 0 || c === void 0 || p.world.backend !== c.world.backend) return false;
|
|
114
|
-
return p.world.backend.contains({
|
|
115
|
-
targetKey: p.rawKey,
|
|
116
|
-
displayPath: parent.displayPath
|
|
117
|
-
}, {
|
|
118
|
-
targetKey: c.rawKey,
|
|
119
|
-
displayPath: child.displayPath
|
|
120
|
-
});
|
|
121
|
-
}
|
|
122
|
-
async stat(target, signal) {
|
|
123
|
-
const decoded = this.decode(String(target.targetKey));
|
|
124
|
-
if (decoded === void 0) return void 0;
|
|
125
|
-
return decoded.world.backend.stat({
|
|
126
|
-
targetKey: decoded.rawKey,
|
|
127
|
-
displayPath: target.displayPath
|
|
128
|
-
}, signal);
|
|
129
|
-
}
|
|
130
|
-
async lstat(path, opts, signal) {
|
|
131
|
-
const world = this.deps.worldFor(opts?.cwd);
|
|
132
|
-
const effectivePath = world.anchorPath !== void 0 && world.remoteRoot !== void 0 ? this.translateAnchorPath(world.anchorPath, world.remoteRoot, path) : path;
|
|
133
|
-
return world.backend.lstat(effectivePath, opts, signal);
|
|
134
|
-
}
|
|
135
|
-
async readText(target, signal) {
|
|
136
|
-
const decoded = this.decode(String(target.targetKey));
|
|
137
|
-
if (decoded === void 0) throw new Error("fs-ssh: cannot route target");
|
|
138
|
-
return decoded.world.backend.readText({
|
|
139
|
-
targetKey: decoded.rawKey,
|
|
140
|
-
displayPath: target.displayPath
|
|
141
|
-
}, signal);
|
|
142
|
-
}
|
|
143
|
-
async streamText(target, signal) {
|
|
144
|
-
const decoded = this.decode(String(target.targetKey));
|
|
145
|
-
if (decoded === void 0) throw new Error("fs-ssh: cannot route target");
|
|
146
|
-
return decoded.world.backend.streamText({
|
|
147
|
-
targetKey: decoded.rawKey,
|
|
148
|
-
displayPath: target.displayPath
|
|
149
|
-
}, signal);
|
|
150
|
-
}
|
|
151
|
-
async readBytes(target, signal, maxBytes) {
|
|
152
|
-
const decoded = this.decode(String(target.targetKey));
|
|
153
|
-
if (decoded === void 0) throw new Error("fs-ssh: cannot route target");
|
|
154
|
-
return decoded.world.backend.readBytes({
|
|
155
|
-
targetKey: decoded.rawKey,
|
|
156
|
-
displayPath: target.displayPath
|
|
157
|
-
}, signal, maxBytes);
|
|
158
|
-
}
|
|
159
|
-
async listDir(target, signal) {
|
|
160
|
-
const decoded = this.decode(String(target.targetKey));
|
|
161
|
-
if (decoded === void 0) throw new Error("fs-ssh: cannot route target");
|
|
162
|
-
const entries = await decoded.world.backend.listDir({
|
|
163
|
-
targetKey: decoded.rawKey,
|
|
164
|
-
displayPath: target.displayPath
|
|
165
|
-
}, signal);
|
|
166
|
-
if (decoded.world.namespace === "") return entries;
|
|
167
|
-
return entries.map((entry) => ({
|
|
168
|
-
...entry,
|
|
169
|
-
target: {
|
|
170
|
-
targetKey: this.encode(String(entry.target.targetKey), decoded.world.namespace),
|
|
171
|
-
displayPath: entry.target.displayPath
|
|
172
|
-
}
|
|
173
|
-
}));
|
|
174
|
-
}
|
|
175
|
-
async writeText(target, content, expected, signal, sandboxPolicy) {
|
|
176
|
-
const decoded = this.decode(String(target.targetKey));
|
|
177
|
-
if (decoded === void 0) throw new Error("fs-ssh: cannot route target");
|
|
178
|
-
return decoded.world.backend.writeText({
|
|
179
|
-
targetKey: decoded.rawKey,
|
|
180
|
-
displayPath: target.displayPath
|
|
181
|
-
}, content, expected, signal, sandboxPolicy);
|
|
182
|
-
}
|
|
183
|
-
async editText(target, edit, expected, signal, sandboxPolicy) {
|
|
184
|
-
const decoded = this.decode(String(target.targetKey));
|
|
185
|
-
if (decoded === void 0) throw new Error("fs-ssh: cannot route target");
|
|
186
|
-
return decoded.world.backend.editText({
|
|
187
|
-
targetKey: decoded.rawKey,
|
|
188
|
-
displayPath: target.displayPath
|
|
189
|
-
}, edit, expected, signal, sandboxPolicy);
|
|
190
|
-
}
|
|
191
|
-
};
|
|
192
|
-
//#endregion
|
|
193
|
-
export { SwitchFileSystem as n, REMOTE_PREFIX as t };
|