dsh-over-ssh 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +119 -2
  3. package/agents/agent-manifest.json +20 -0
  4. package/agents/linux-amd64/dsh-over-ssh-agent +0 -0
  5. package/agents/linux-arm64/dsh-over-ssh-agent +0 -0
  6. package/cordis.patch.yml +84 -0
  7. package/lib/client.js +6660 -0
  8. package/lib/file-references.js +411 -0
  9. package/lib/fs.js +1451 -0
  10. package/lib/index.js +19740 -0
  11. package/lib/local-bridge-refs.js +16 -0
  12. package/lib/local-bridge.js +16 -0
  13. package/lib/terminal-wrapper.js +299 -0
  14. package/lib/types/src/client/index.d.ts +49 -0
  15. package/lib/types/src/config.d.ts +383 -0
  16. package/lib/types/src/file-references.d.ts +24 -0
  17. package/lib/types/src/host/app/backend.d.ts +178 -0
  18. package/lib/types/src/host/app/recycle-bin/recycle-bin-contract.d.ts +246 -0
  19. package/lib/types/src/host/app/recycle-bin/recycle-bin-service.d.ts +44 -0
  20. package/lib/types/src/host/app/session-admin/session-admin-contract.d.ts +232 -0
  21. package/lib/types/src/host/app/session-admin/session-admin-service.d.ts +39 -0
  22. package/lib/types/src/host/app/transport/transport-selector.d.ts +97 -0
  23. package/lib/types/src/host/app/transport/transport-service.d.ts +81 -0
  24. package/lib/types/src/host/domain/capability.d.ts +77 -0
  25. package/lib/types/src/host/domain/errors.d.ts +54 -0
  26. package/lib/types/src/host/domain/roots/connection.d.ts +105 -0
  27. package/lib/types/src/host/domain/roots/root.d.ts +123 -0
  28. package/lib/types/src/host/domain/roots/session-roots.d.ts +150 -0
  29. package/lib/types/src/host/domain/roots/ssh-config.d.ts +72 -0
  30. package/lib/types/src/host/domain/session/session-admin.d.ts +344 -0
  31. package/lib/types/src/host/domain/transfer/recycle-bin.d.ts +336 -0
  32. package/lib/types/src/host/domain/transfer/transfer.d.ts +259 -0
  33. package/lib/types/src/host/infra/transport/agent/agent-installer.d.ts +120 -0
  34. package/lib/types/src/host/infra/transport/ssh/ssh2-transport.d.ts +327 -0
  35. package/lib/types/src/host/infra/transport/transport-base.d.ts +33 -0
  36. package/lib/types/src/host/infra/transport/transport.d.ts +541 -0
  37. package/lib/types/src/host/integration/agent/agent-preflight.d.ts +55 -0
  38. package/lib/types/src/host/integration/fs/file-reference-provider.d.ts +141 -0
  39. package/lib/types/src/host/integration/fs/fs-provider.d.ts +366 -0
  40. package/lib/types/src/host/integration/transfer/transfer-service.d.ts +211 -0
  41. package/lib/types/src/index.d.ts +88 -0
  42. package/lib/types/src/local-bridge-refs.d.ts +25 -0
  43. package/lib/types/src/local-bridge.d.ts +35 -0
  44. package/lib/types/src/shared/format.d.ts +13 -0
  45. package/lib/types/src/shared/root-keys.d.ts +120 -0
  46. package/lib/types/src/wiring/helpers.d.ts +143 -0
  47. package/package.json +122 -4
@@ -0,0 +1,16 @@
1
+ // src/local-bridge-refs.ts
2
+ import "@deepseek-ai/cordis";
3
+ var LocalFileReferencesBridge = class {
4
+ static inject = ["fileReferences"];
5
+ constructor(ctx) {
6
+ const local = ctx.get("fileReferences");
7
+ if (local === void 0) {
8
+ ctx.logger?.warn?.("local-bridge-refs: \u9694\u79BB\u57DF\u91CC\u6CA1\u6709 fileReferences\uFF1B\u8DEF\u7531\u5668\u5C06\u6CA1\u6709\u672C\u5730\u540E\u7AEF");
9
+ return;
10
+ }
11
+ ctx.provide("localFileReferences", local);
12
+ }
13
+ };
14
+ export {
15
+ LocalFileReferencesBridge as default
16
+ };
@@ -0,0 +1,16 @@
1
+ // src/local-bridge.ts
2
+ import "@deepseek-ai/cordis";
3
+ var LocalFsBridge = class {
4
+ static inject = ["fs"];
5
+ constructor(ctx) {
6
+ const local = ctx.get("fs");
7
+ if (local === void 0) {
8
+ ctx.logger?.warn?.("local-bridge: \u9694\u79BB\u57DF\u91CC\u6CA1\u6709 fs\uFF1B\u8DEF\u7531\u5668\u5C06\u6CA1\u6709\u672C\u5730\u540E\u7AEF");
9
+ return;
10
+ }
11
+ ctx.provide("localFs", local);
12
+ }
13
+ };
14
+ export {
15
+ LocalFsBridge as default
16
+ };
@@ -0,0 +1,299 @@
1
+ #!/usr/bin/env -S node
2
+
3
+ // tools/over-ssh-terminal.ts
4
+ import { spawn } from "node:child_process";
5
+ import { existsSync } from "node:fs";
6
+ import { readFile } from "node:fs/promises";
7
+ import { homedir } from "node:os";
8
+ import { join } from "node:path";
9
+ import { fileURLToPath } from "node:url";
10
+
11
+ // src/host/domain/errors.ts
12
+ var SshDevError = class extends Error {
13
+ code;
14
+ details;
15
+ constructor(code, message, details = {}) {
16
+ super(message);
17
+ this.name = "SshDevError";
18
+ this.code = code;
19
+ this.details = details;
20
+ }
21
+ };
22
+
23
+ // src/shared/root-keys.ts
24
+ function normalizeLocalPath(path, options = {}) {
25
+ const caseInsensitive = options.caseInsensitive ?? false;
26
+ const unified = path.replace(/\\/g, "/");
27
+ const unc = unified.startsWith("//");
28
+ const posixAbsolute = unified.startsWith("/");
29
+ const driveMatch = /^([A-Za-z]):/.exec(unified);
30
+ const driveLetter = driveMatch?.[1];
31
+ const drive = driveLetter === void 0 ? "" : `${driveLetter.toUpperCase()}:`;
32
+ const rest = unc ? unified.slice(2) : drive === "" ? unified : unified.slice(2);
33
+ const segments = [];
34
+ for (const segment of rest.split("/")) {
35
+ if (segment === "" || segment === ".") continue;
36
+ if (segment === "..") {
37
+ segments.pop();
38
+ continue;
39
+ }
40
+ segments.push(segment);
41
+ }
42
+ const body = segments.join("/");
43
+ let normalized;
44
+ if (unc) normalized = `//${body}`;
45
+ else if (drive !== "") normalized = body === "" ? `${drive}/` : `${drive}/${body}`;
46
+ else if (posixAbsolute) normalized = body === "" ? "/" : `/${body}`;
47
+ else normalized = body === "" ? "." : body;
48
+ return caseInsensitive ? normalized.toLowerCase() : normalized;
49
+ }
50
+
51
+ // src/host/domain/roots/root.ts
52
+ function normalizeLocalPath2(path, options = {}) {
53
+ return normalizeLocalPath(path, { caseInsensitive: options.caseInsensitive ?? process.platform === "win32" });
54
+ }
55
+ function localPathContains(root, candidate, options = {}) {
56
+ const normalizedRoot = normalizeLocalPath2(root, options);
57
+ const normalizedCandidate = normalizeLocalPath2(candidate, options);
58
+ if (normalizedRoot === "/" || /^[a-z]:\/$/.test(normalizedRoot)) {
59
+ return normalizedCandidate.startsWith(normalizedRoot);
60
+ }
61
+ return normalizedCandidate === normalizedRoot || normalizedCandidate.startsWith(`${normalizedRoot}/`);
62
+ }
63
+ function fnv1aHex(input) {
64
+ let hash = 2166136261;
65
+ for (let index = 0; index < input.length; index++) {
66
+ hash ^= input.charCodeAt(index);
67
+ hash = Math.imul(hash, 16777619) >>> 0;
68
+ }
69
+ return hash.toString(16).padStart(8, "0");
70
+ }
71
+
72
+ // src/host/domain/shell.ts
73
+ function quote(value) {
74
+ if (value.includes("\0")) {
75
+ throw new SshDevError("REMOTE_COMMAND_FAILED", "\u8DEF\u5F84\u6216\u53C2\u6570\u4E2D\u4E0D\u80FD\u5305\u542B NUL \u5B57\u7B26", {});
76
+ }
77
+ return `'${value.replaceAll("'", "'\\''")}'`;
78
+ }
79
+
80
+ // src/host/domain/session/terminal-target.ts
81
+ var MANIFEST_VERSION = 1;
82
+ var SESSION_STATE_MAX_AGE_MS = 24 * 60 * 60 * 1e3;
83
+ function parseRootManifest(raw) {
84
+ let parsed;
85
+ try {
86
+ parsed = JSON.parse(raw);
87
+ } catch {
88
+ return void 0;
89
+ }
90
+ if (parsed === null || typeof parsed !== "object") return void 0;
91
+ const candidate = parsed;
92
+ if (candidate.version !== MANIFEST_VERSION) return void 0;
93
+ if (!Array.isArray(candidate.roots)) return void 0;
94
+ const roots = [];
95
+ for (const entry of candidate.roots) {
96
+ if (entry === null || typeof entry !== "object") continue;
97
+ const root = entry;
98
+ if (typeof root.connectionId !== "string" || typeof root.remoteRoot !== "string") continue;
99
+ if (typeof root.anchorPath !== "string" || root.anchorPath === "") continue;
100
+ if (root.ssh === void 0 || typeof root.ssh.destination !== "string" || !Array.isArray(root.ssh.args)) continue;
101
+ roots.push({
102
+ connectionId: root.connectionId,
103
+ remoteRoot: root.remoteRoot,
104
+ anchorPath: root.anchorPath,
105
+ ssh: { destination: root.ssh.destination, args: [...root.ssh.args] }
106
+ });
107
+ }
108
+ return { version: MANIFEST_VERSION, roots };
109
+ }
110
+ function parseSessionState(raw) {
111
+ let parsed;
112
+ try {
113
+ parsed = JSON.parse(raw);
114
+ } catch {
115
+ return void 0;
116
+ }
117
+ if (parsed === null || typeof parsed !== "object") return void 0;
118
+ const state = parsed;
119
+ if (typeof state.sessionId !== "string" || state.sessionId === "") return void 0;
120
+ if (typeof state.connectionId !== "string" || typeof state.remoteRoot !== "string") return void 0;
121
+ if (typeof state.recordedAt !== "number" || !Number.isFinite(state.recordedAt)) return void 0;
122
+ if (state.ssh === void 0 || typeof state.ssh.destination !== "string" || !Array.isArray(state.ssh.args)) return void 0;
123
+ return {
124
+ sessionId: state.sessionId,
125
+ connectionId: state.connectionId,
126
+ remoteRoot: state.remoteRoot,
127
+ ssh: { destination: state.ssh.destination, args: [...state.ssh.args] },
128
+ recordedAt: state.recordedAt
129
+ };
130
+ }
131
+ function remoteCommandFor(remoteRoot, remoteShell = "$SHELL") {
132
+ return `cd ${quote(remoteRoot)} && exec ${remoteShell} -l`;
133
+ }
134
+ function sshArgvFor(ssh, remoteCommand) {
135
+ return ["ssh", "-tt", ...ssh.args, ssh.destination, "--", remoteCommand];
136
+ }
137
+ function decideTerminal(options) {
138
+ const now = options.now ?? Date.now();
139
+ const local = { kind: "local", argv: [options.localShell], reason: "no-target" };
140
+ const state = options.sessionState;
141
+ if (state !== void 0) {
142
+ if (now - state.recordedAt > SESSION_STATE_MAX_AGE_MS || state.recordedAt > now + 6e4) {
143
+ const manifestRoot = matchRoot(options.manifest, options.cwd, now);
144
+ return manifestRoot === void 0 ? { ...local, reason: "state-unusable" } : remoteDecision(manifestRoot, "anchor", options.remoteShell);
145
+ }
146
+ const command = remoteCommandFor(state.remoteRoot, options.remoteShell);
147
+ return {
148
+ kind: "remote",
149
+ connectionId: state.connectionId,
150
+ remoteRoot: state.remoteRoot,
151
+ argv: sshArgvFor(state.ssh, command),
152
+ remoteCommand: command,
153
+ reason: "session"
154
+ };
155
+ }
156
+ const matched = matchRoot(options.manifest, options.cwd, now);
157
+ if (matched !== void 0) return remoteDecision(matched, "anchor", options.remoteShell);
158
+ return options.manifest === void 0 ? { ...local, reason: "no-target" } : local;
159
+ }
160
+ function matchRoot(manifest, cwd, _now) {
161
+ if (manifest === void 0) return void 0;
162
+ return manifest.roots.find((root) => localPathContains(root.anchorPath, cwd, { caseInsensitive: false }));
163
+ }
164
+ function remoteDecision(root, reason, remoteShell) {
165
+ const command = remoteCommandFor(root.remoteRoot, remoteShell ?? "$SHELL");
166
+ return {
167
+ kind: "remote",
168
+ connectionId: root.connectionId,
169
+ remoteRoot: root.remoteRoot,
170
+ argv: sshArgvFor(root.ssh, command),
171
+ remoteCommand: command,
172
+ reason
173
+ };
174
+ }
175
+
176
+ // src/host/integration/tools/terminal-manifest.ts
177
+ function sessionFileName(sessionId) {
178
+ const sanitized = sessionId.replaceAll(/[^A-Za-z0-9._-]/g, "_").slice(0, 48);
179
+ return `${sanitized}-${fnv1aHex(sessionId)}.json`;
180
+ }
181
+
182
+ // tools/over-ssh-terminal.ts
183
+ var RECONNECT_DELAYS_MS = [500, 1e3, 2e3, 4e3];
184
+ var SSH_CONNECTION_FAILED = 255;
185
+ function parseWrapperArgs(argv) {
186
+ const flags = /* @__PURE__ */ new Map();
187
+ for (let index = 0; index < argv.length; index++) {
188
+ const token = argv[index];
189
+ if (token === void 0 || !token.startsWith("--")) continue;
190
+ const next = argv[index + 1];
191
+ if (next !== void 0 && !next.startsWith("--")) {
192
+ flags.set(token.slice(2), next);
193
+ index++;
194
+ } else {
195
+ flags.set(token.slice(2), "true");
196
+ }
197
+ }
198
+ return flags;
199
+ }
200
+ function defaultLocalShell(platform, env) {
201
+ if (platform === "win32") return env["COMSPEC"] ?? "cmd.exe";
202
+ return env["SHELL"] ?? "/bin/sh";
203
+ }
204
+ async function readIfPresent(path) {
205
+ try {
206
+ return await readFile(path, "utf8");
207
+ } catch {
208
+ return void 0;
209
+ }
210
+ }
211
+ async function loadInputs(options) {
212
+ const manifestRaw = await readIfPresent(join(options.stateDir, "roots.json"));
213
+ const manifest = manifestRaw === void 0 ? void 0 : parseRootManifest(manifestRaw);
214
+ if (options.sessionId === void 0) return { manifest, sessionState: void 0 };
215
+ const stateRaw = await readIfPresent(join(options.stateDir, "state", sessionFileName(options.sessionId)));
216
+ return { manifest, sessionState: stateRaw === void 0 ? void 0 : parseSessionState(stateRaw) };
217
+ }
218
+ var STATE_DIR_NAMES = ["over-ssh", "remote-workspace", "ssh-dev"];
219
+ function defaultStateDir() {
220
+ const base = join(homedir(), ".dsh");
221
+ for (const name of STATE_DIR_NAMES) {
222
+ const dir = join(base, name);
223
+ if (existsSync(join(dir, "roots.json"))) return dir;
224
+ }
225
+ return join(base, STATE_DIR_NAMES[0]);
226
+ }
227
+ async function decide(input) {
228
+ const flags = parseWrapperArgs(input.argv);
229
+ const stateDir = flags.get("state-dir") ?? input.env["DSH_OVER_SSH_STATE_DIR"] ?? input.env["DSH_SSH_DEV_STATE_DIR"] ?? defaultStateDir();
230
+ const sessionId = flags.get("session") ?? input.env["DSH_SESSION_ID"];
231
+ const localShell = flags.get("local-shell") ?? defaultLocalShell(input.platform ?? process.platform, input.env);
232
+ const inputs = await loadInputs({ stateDir, sessionId });
233
+ const decideOptions = {
234
+ cwd: input.cwd,
235
+ localShell,
236
+ ...inputs.manifest === void 0 ? {} : { manifest: inputs.manifest },
237
+ ...inputs.sessionState === void 0 ? {} : { sessionState: inputs.sessionState }
238
+ };
239
+ const base = decideTerminal(decideOptions);
240
+ const override = flags.get("remote-command");
241
+ const decision = override === void 0 || base.kind !== "remote" ? base : { ...base, argv: [...base.argv.slice(0, -1), override], remoteCommand: override };
242
+ return { decision, label: flags.get("name") ?? "SSH / Local", stateDir, sessionId, localShell };
243
+ }
244
+ async function main(input) {
245
+ const { decision } = await decide(input);
246
+ if (parseWrapperArgs(input.argv).has("dry-run")) {
247
+ process.stdout.write(`${JSON.stringify(decision, void 0, 2)}
248
+ `);
249
+ return 0;
250
+ }
251
+ const [executable, ...args] = decision.argv;
252
+ if (executable === void 0) throw new Error("terminal wrapper: empty argv");
253
+ const spawnFn = input.spawnFn ?? spawn;
254
+ const sleepFn = input.sleepFn ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
255
+ const runOnce = async () => {
256
+ const child = spawnFn(executable, args, {
257
+ stdio: "inherit",
258
+ // A local terminal keeps the caller's cwd; a remote one is entered by the ssh command itself.
259
+ ...decision.kind === "local" ? { cwd: input.cwd } : {}
260
+ });
261
+ return await new Promise((resolve) => {
262
+ child.on("exit", (code, signal) => {
263
+ if (typeof code === "number") resolve({ code, signalled: false });
264
+ else resolve({ code: signal === null || signal === void 0 ? 0 : 1, signalled: true });
265
+ });
266
+ child.on("error", () => resolve({ code: 127, signalled: false }));
267
+ });
268
+ };
269
+ const first = await runOnce();
270
+ if (decision.kind !== "remote" || parseWrapperArgs(input.argv).has("no-reconnect")) return first.code;
271
+ let outcome = first;
272
+ for (const delay of RECONNECT_DELAYS_MS) {
273
+ if (outcome.signalled || outcome.code !== SSH_CONNECTION_FAILED) return outcome.code;
274
+ process.stderr.write(
275
+ `\r
276
+ [dsh] \u4E0E\u8FDC\u7AEF\u7684\u8FDE\u63A5\u5DF2\u65AD\u5F00\uFF0C${delay} ms \u540E\u91CD\u8FDE\uFF08\u65B0\u7684\u767B\u5F55 shell\uFF1B\u539F\u5148\u8FD0\u884C\u7684\u8FDB\u7A0B\u5DF2\u4E22\u5931\uFF09\u2026\r
277
+ `
278
+ );
279
+ await sleepFn(delay);
280
+ outcome = await runOnce();
281
+ }
282
+ return outcome.code;
283
+ }
284
+ var selfPath = fileURLToPath(import.meta.url);
285
+ if (process.argv[1] !== void 0 && process.argv[1] === selfPath) {
286
+ main({ argv: process.argv.slice(2), env: process.env, cwd: process.cwd(), platform: process.platform }).then((code) => process.exit(code)).catch((error) => {
287
+ process.stderr.write(`over-ssh terminal wrapper: ${error instanceof Error ? error.message : String(error)}
288
+ `);
289
+ process.exit(1);
290
+ });
291
+ }
292
+ export {
293
+ RECONNECT_DELAYS_MS,
294
+ decide,
295
+ defaultLocalShell,
296
+ loadInputs,
297
+ main,
298
+ parseWrapperArgs
299
+ };
@@ -0,0 +1,49 @@
1
+ /**
2
+ * The web client half: one settings section that explains how this plugin chooses a transport, the connection
3
+ * list once the host answers, the editable remote-workspaces editor (`src/client/roots-panel-view.ts`) that
4
+ * adds and removes roots and connections through the host's same-origin `/api/over-ssh/roots` route,
5
+ * the **remote-files tab** (`src/client/roots/remote-files-tab.ts`) that the editor opens into the sidebar's
6
+ * right column,
7
+ * the **directory-flow occupant** (`src/client/workspace-directory-flow.ts`) that both workspace surfaces
8
+ * render their "add workspace" action from, and the **session entry**
9
+ * (`src/client/session-directory.ts`) that adds a directory to the workspace a session is already in,
10
+ * without creating a workspace.
11
+ *
12
+ * Both browsers of the flow and the session entry are the same component
13
+ * (`src/client/directory-browser.ts`): local levels come from this plugin's own read-only `list-local`
14
+ * action and remote levels from its `list-remote` action, both through the same same-origin route. The
15
+ * platform's `uiWorkspace.listDirectory()` is deliberately **not** used: it needs the directory-picker's
16
+ * `browse` capability, which a profile whose composed picker serves only the native dialog does not have
17
+ * (see `doc/02-platform/roots-http-route.md` §2).
18
+ *
19
+ * Deliberately small, but structurally complete:
20
+ *
21
+ * - dictionaries are registered through the **typed** `ctx.locale.register` (both shipped locales required);
22
+ * - the section is registered through `ctx.slots.register` inside `ctx.effect`, so unloading the plugin
23
+ * removes it;
24
+ * - the directory flow and the session entry go through `ctx.slots.inject` instead, because their holes
25
+ * belong to other entries (the workspace surfaces, the composer), which may activate before or after this
26
+ * plugin and whose absence must not fail the rest of this half;
27
+ * - the panel receives its data through the registration's `inject` face, never through `ctx` (components
28
+ * cannot see `ctx` — platform contract);
29
+ * - the section re-registers on `locale/change`, because registrant options carry localized text and the
30
+ * shell does not subscribe to locale state on the registrant's behalf; the same re-registration is what
31
+ * brings the first host rows onto the screen (the slot's inject face is a synchronous snapshot, so new
32
+ * data needs a fresh registration until an observable source replaces it);
33
+ * - the host channel is mounted here, by this plugin: `api-remotes` only mounts the packages it lists, and a
34
+ * third-party half is not one of them. `remote.<namespace>` is therefore **not** in `inject` — waiting for
35
+ * a service this plugin creates itself would deadlock activation;
36
+ * - the **right sidebar** is an optional seam like `uiWorkspace`: its two services are read through
37
+ * `ctx.get`, never awaited, so a profile without that column loses one tab and nothing else.
38
+ *
39
+ * @module dsh-over-ssh/client
40
+ */
41
+ import { Context } from '@deepseek-ai/cordis';
42
+ /** Client services this half needs. `remote` is injected; `remote.sshdev` is mounted by `apply` below. */
43
+ export declare const inject: string[];
44
+ /**
45
+ * Install the client half.
46
+ *
47
+ * @param ctx - client plugin context (has `slots`, `locale` and `remote` by injection).
48
+ */
49
+ export declare function apply(ctx: Context): void;