@tiphareth/dsh-hardssh 0.1.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 (165) hide show
  1. package/README.md +50 -0
  2. package/cordis.patch.yml +26 -0
  3. package/lib/client.js +17780 -0
  4. package/lib/environment-BL1jddfB.js +449 -0
  5. package/lib/fs.js +478 -0
  6. package/lib/index.js +6758 -0
  7. package/lib/subprocess.js +600 -0
  8. package/lib/switch-fs-CAJpFY9C.js +193 -0
  9. package/lib/switch-fs-RrZtG2gv.js +210 -0
  10. package/lib/types/backend.d.ts +108 -0
  11. package/lib/types/base/capability.d.ts +107 -0
  12. package/lib/types/base/index.d.ts +18 -0
  13. package/lib/types/base/ledger-router.d.ts +48 -0
  14. package/lib/types/base/ledger.d.ts +82 -0
  15. package/lib/types/base/model.d.ts +108 -0
  16. package/lib/types/base/namespace.d.ts +57 -0
  17. package/lib/types/base/plugin.d.ts +102 -0
  18. package/lib/types/base/registry.d.ts +60 -0
  19. package/lib/types/base/router.d.ts +40 -0
  20. package/lib/types/client/api.d.ts +85 -0
  21. package/lib/types/client/directory-flow.d.ts +82 -0
  22. package/lib/types/client/icons.d.ts +19 -0
  23. package/lib/types/client/index.d.ts +34 -0
  24. package/lib/types/client/locales.d.ts +104 -0
  25. package/lib/types/client/manager-button.d.ts +32 -0
  26. package/lib/types/client/migrate.d.ts +20 -0
  27. package/lib/types/client/ssh/api.d.ts +89 -0
  28. package/lib/types/client/ssh/apply.d.ts +23 -0
  29. package/lib/types/client/ssh/locales.d.ts +156 -0
  30. package/lib/types/client/ssh/mount.d.ts +13 -0
  31. package/lib/types/client/ssh/panel/ClusterTab.d.ts +8 -0
  32. package/lib/types/client/ssh/panel/HostFingerprintDialog.d.ts +16 -0
  33. package/lib/types/client/ssh/panel/HostFormDialog.d.ts +13 -0
  34. package/lib/types/client/ssh/panel/HostsTab.d.ts +10 -0
  35. package/lib/types/client/ssh/panel/SessionSecretDialog.d.ts +16 -0
  36. package/lib/types/client/ssh/panel/SshPanel.d.ts +14 -0
  37. package/lib/types/client/ssh/panel/TerminalTab.d.ts +12 -0
  38. package/lib/types/client/ssh/panel/TransferTab.d.ts +8 -0
  39. package/lib/types/client/ssh/panel/TunnelsTab.d.ts +8 -0
  40. package/lib/types/client/ssh/panel/controller.d.ts +23 -0
  41. package/lib/types/client/ssh/panel/helpers.d.ts +15 -0
  42. package/lib/types/client/ssh/panel/xterm.css.d.ts +3 -0
  43. package/lib/types/client/ssh/sidebar-entry.d.ts +25 -0
  44. package/lib/types/client/state.d.ts +32 -0
  45. package/lib/types/client/text.d.ts +11 -0
  46. package/lib/types/client/workspace-badges.d.ts +38 -0
  47. package/lib/types/client/workspace-gate.d.ts +15 -0
  48. package/lib/types/client-http.d.ts +15 -0
  49. package/lib/types/core.d.ts +43 -0
  50. package/lib/types/fs.d.ts +36 -0
  51. package/lib/types/host-http.d.ts +25 -0
  52. package/lib/types/index.d.ts +53 -0
  53. package/lib/types/ledger.d.ts +132 -0
  54. package/lib/types/protocol.d.ts +101 -0
  55. package/lib/types/providers/index.d.ts +19 -0
  56. package/lib/types/providers/local/provider.d.ts +58 -0
  57. package/lib/types/providers/ssh/provider.d.ts +112 -0
  58. package/lib/types/remote/environment.d.ts +29 -0
  59. package/lib/types/remote/output.d.ts +38 -0
  60. package/lib/types/remote/remote-fs.d.ts +69 -0
  61. package/lib/types/remote/remote-process.d.ts +41 -0
  62. package/lib/types/remote/remote-subprocess.d.ts +31 -0
  63. package/lib/types/remote/remote-terminal.d.ts +41 -0
  64. package/lib/types/remote-runner.d.ts +83 -0
  65. package/lib/types/remote-search.d.ts +50 -0
  66. package/lib/types/routes.d.ts +24 -0
  67. package/lib/types/runtime/workspace-core.d.ts +59 -0
  68. package/lib/types/seam-state.d.ts +69 -0
  69. package/lib/types/shell.d.ts +8 -0
  70. package/lib/types/ssh/connection/lease.d.ts +16 -0
  71. package/lib/types/ssh/connection/pool.d.ts +57 -0
  72. package/lib/types/ssh/engine.d.ts +434 -0
  73. package/lib/types/ssh/exec/output.d.ts +31 -0
  74. package/lib/types/ssh/known-hosts.d.ts +89 -0
  75. package/lib/types/ssh/plugin.d.ts +57 -0
  76. package/lib/types/ssh/protocol.d.ts +236 -0
  77. package/lib/types/ssh/routes.d.ts +44 -0
  78. package/lib/types/ssh/store.d.ts +107 -0
  79. package/lib/types/ssh/tools.d.ts +35 -0
  80. package/lib/types/ssh/transfer/progress.d.ts +14 -0
  81. package/lib/types/ssh/vault.d.ts +110 -0
  82. package/lib/types/subprocess.d.ts +32 -0
  83. package/lib/types/switch/switch-fs.d.ts +88 -0
  84. package/lib/types/switch/switch-subprocess.d.ts +34 -0
  85. package/lib/types/tools.d.ts +12 -0
  86. package/package.json +134 -0
  87. package/src/backend.ts +624 -0
  88. package/src/base/capability.ts +86 -0
  89. package/src/base/index.ts +18 -0
  90. package/src/base/ledger-router.ts +128 -0
  91. package/src/base/ledger.ts +299 -0
  92. package/src/base/model.ts +118 -0
  93. package/src/base/namespace.ts +102 -0
  94. package/src/base/plugin.ts +170 -0
  95. package/src/base/registry.ts +109 -0
  96. package/src/base/router.ts +43 -0
  97. package/src/client/api.ts +196 -0
  98. package/src/client/css-modules.d.ts +5 -0
  99. package/src/client/directory-flow.tsx +482 -0
  100. package/src/client/icons.tsx +50 -0
  101. package/src/client/index.ts +210 -0
  102. package/src/client/locales.ts +105 -0
  103. package/src/client/manager-button.tsx +269 -0
  104. package/src/client/migrate.ts +109 -0
  105. package/src/client/ssh/api.ts +411 -0
  106. package/src/client/ssh/apply.ts +50 -0
  107. package/src/client/ssh/locales.ts +322 -0
  108. package/src/client/ssh/mount.tsx +83 -0
  109. package/src/client/ssh/panel/ClusterTab.tsx +123 -0
  110. package/src/client/ssh/panel/HostFingerprintDialog.tsx +82 -0
  111. package/src/client/ssh/panel/HostFormDialog.tsx +228 -0
  112. package/src/client/ssh/panel/HostsTab.tsx +236 -0
  113. package/src/client/ssh/panel/SessionSecretDialog.tsx +80 -0
  114. package/src/client/ssh/panel/SshPanel.tsx +77 -0
  115. package/src/client/ssh/panel/TerminalTab.tsx +176 -0
  116. package/src/client/ssh/panel/TransferTab.tsx +232 -0
  117. package/src/client/ssh/panel/TunnelsTab.tsx +177 -0
  118. package/src/client/ssh/panel/controller.ts +48 -0
  119. package/src/client/ssh/panel/helpers.ts +26 -0
  120. package/src/client/ssh/panel/panel.module.css +1006 -0
  121. package/src/client/ssh/panel/xterm.css.ts +2 -0
  122. package/src/client/ssh/sidebar-entry.ts +123 -0
  123. package/src/client/state.ts +99 -0
  124. package/src/client/text.ts +22 -0
  125. package/src/client/workspace-badges.ts +96 -0
  126. package/src/client/workspace-gate.ts +225 -0
  127. package/src/client/workspace.module.css +283 -0
  128. package/src/client-http.ts +45 -0
  129. package/src/core.ts +47 -0
  130. package/src/fs.ts +85 -0
  131. package/src/host-http.ts +74 -0
  132. package/src/index.ts +244 -0
  133. package/src/ledger.ts +416 -0
  134. package/src/protocol.ts +114 -0
  135. package/src/providers/index.ts +34 -0
  136. package/src/providers/local/provider.ts +179 -0
  137. package/src/providers/ssh/provider.ts +274 -0
  138. package/src/remote/environment.ts +123 -0
  139. package/src/remote/output.ts +142 -0
  140. package/src/remote/remote-fs.ts +532 -0
  141. package/src/remote/remote-process.ts +203 -0
  142. package/src/remote/remote-subprocess.ts +159 -0
  143. package/src/remote/remote-terminal.ts +141 -0
  144. package/src/remote-runner.ts +201 -0
  145. package/src/remote-search.ts +163 -0
  146. package/src/routes.ts +395 -0
  147. package/src/runtime/workspace-core.ts +154 -0
  148. package/src/seam-state.ts +185 -0
  149. package/src/shell.ts +10 -0
  150. package/src/ssh/connection/lease.ts +17 -0
  151. package/src/ssh/connection/pool.ts +275 -0
  152. package/src/ssh/engine.ts +1761 -0
  153. package/src/ssh/exec/output.ts +70 -0
  154. package/src/ssh/known-hosts.ts +214 -0
  155. package/src/ssh/plugin.ts +184 -0
  156. package/src/ssh/protocol.ts +227 -0
  157. package/src/ssh/routes.ts +892 -0
  158. package/src/ssh/store.ts +544 -0
  159. package/src/ssh/tools.ts +402 -0
  160. package/src/ssh/transfer/progress.ts +75 -0
  161. package/src/ssh/vault.ts +477 -0
  162. package/src/subprocess.ts +71 -0
  163. package/src/switch/switch-fs.ts +253 -0
  164. package/src/switch/switch-subprocess.ts +59 -0
  165. package/src/tools.ts +221 -0
@@ -0,0 +1,449 @@
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 };