@hahahhh/sshx 0.0.6-rc.0 → 0.0.6-rc.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.
Files changed (3) hide show
  1. package/README.md +19 -16
  2. package/bin/sshx.js +106 -2
  3. package/package.json +3 -2
package/README.md CHANGED
@@ -5,7 +5,7 @@
5
5
  **sshx** is a drop-in wrapper around OpenSSH. Wrap it as `alias ssh=sshx` and your existing SSH workflow works exactly as before — every flag, config, and connection passes through verbatim. But when you connect to a host (or Docker container) with sshx-aware features enabled, you unlock a connection-scoped shared remote server that gives you:
6
6
 
7
7
  - 🔄 **Reverse command bridge** — run `sshx local <cmd>` *on the remote* to execute commands on your local machine, with stdout, stderr, exit code, and stdin all propagated.
8
- - 📁 **Remote-to-local filesystem** — opt in to let local tools work directly with files from the active remote session.
8
+ - 📁 **Bidirectional workspace mount** — direct CLI sessions can use remote tools on local files, and local tools on remote files.
9
9
  - 🔌 **Automatic port forwarding** — remote local listeners (loopback `127.0.0.1` and wildcard `0.0.0.0`; e.g., a dev server on `0.0.0.0:8080` or `localhost:8080`) are automatically detected and forwarded to your local machine.
10
10
  - 🌐 **Local domain binding** — access forwarded ports as `<host>.<your-user>.sshx:<port>` in your local browser, no manual `-L` flags needed.
11
11
  - 🐳 **Docker container support** — target running containers by name or ID: `sshx my-container`. Command bridge support works inside containers via `docker exec`.
@@ -26,7 +26,7 @@ sshx is designed to be **safe to alias**. Hosts without sshx configuration are u
26
26
 
27
27
  ## Architecture
28
28
 
29
- ![sshx system architecture](docs/architecture.svg)
29
+ ![sshx system architecture](docs/assets/architecture.svg)
30
30
 
31
31
  Each user-visible session remains a normal OpenSSH connection (or a `docker exec` session for container targets). One hidden multiplexed sidecar channel carries control and optional RemoteFS traffic to a shared target-side server, while one on-demand local daemon owns DNS records and TCP forwards across active client terminals. Automatic port forwarding uses SSH `direct-tcpip`; Docker targets support shells and the command bridge but do not use automatic port scanning.
32
32
 
@@ -68,17 +68,19 @@ sshx local --timeout=30 npm test
68
68
  - Commands have no implicit deadline. Put `--timeout=<duration>` immediately after the target to opt in; bare numbers mean seconds, and values such as `500ms`, `30s`, and `2m` are accepted. Timed-out commands exit with status 124.
69
69
  - Policy: a configurable deny list controls which commands are blocked.
70
70
 
71
- ### 📁 Remote-to-Local Filesystem (opt-in, beta)
71
+ ### 📁 Bidirectional Workspace Mount (opt-in, beta)
72
72
 
73
- Set `features.remoteFs: true` to let a command launched with `sshx local <cmd>` access the remote working tree through a local FUSE mount:
73
+ Set `features.remoteFs: true` to expose the command initiator's workspace through a read-write FUSE mount:
74
74
 
75
+ - A direct `sshx remote <cmd>` exports the local home (or the current directory when outside the home), mounts it on the remote Linux target, and starts the remote command in the mapped local working directory.
76
+ - A direct interactive `sshx remote` shell still starts in the remote home; `SSHX_MOUNT_ROOT` and `SSHX_WORKSPACE` point to the mounted local source tree.
75
77
  - The remote home is exported lazily on the first local command. A working directory outside the remote home creates a separate lazy export for that root.
76
78
  - The local command starts at the corresponding mounted working directory. Absolute command arguments are not rewritten.
77
79
  - Mounts are reused for the sidecar session, so detached tools such as `open` and `code` can continue reading files after the bridge request returns.
78
- - Local files are never exported or mounted on the remote host.
80
+ - VS Code/Cursor Remote-SSH integration sidecars stay remote-to-local only: those application sessions do not export a local workspace to the remote host.
79
81
  - With RemoteFS disabled, the command runs from the local home and receives `SSHX_REMOTE_CWD` plus `SSHX_REMOTE_FS=0`.
80
82
 
81
- The mounted remote tree permits reads, writes, and creation, but blocks file/directory deletion and rename. It can include sensitive remote files such as shell configuration and SSH credentials, so enable `remoteFs` only for targets you trust.
83
+ Mounted trees permit reads, writes, and creation, but block file/directory deletion and rename in both directions. They can include sensitive files such as shell configuration and SSH credentials, so enable `remoteFs` only for targets you trust.
82
84
 
83
85
  Set `FS_READ_ONLY=1` on the client when starting sshx to make the session mounts read-only:
84
86
 
@@ -88,13 +90,13 @@ FS_READ_ONLY=1 sshx debian@orb pwd
88
90
 
89
91
  The value is exported into the remote session, and later `sshx local <cmd>` mounts remain read-only.
90
92
 
91
- FUSE is required only on the local machine receiving the mounted remote tree. A mount failure fails that `sshx local` invocation instead of running it from the wrong directory. Linux needs `/dev/fuse` plus `fusermount`/`fusermount3`; macOS needs a current macFUSE installation and is currently beta.
93
+ FUSE is required on the machine receiving a mounted tree. Direct `sshx remote` sessions therefore require FUSE on the remote Linux target; `sshx local` with RemoteFS requires FUSE on the local client. A mount failure fails the invocation instead of running it from the wrong directory.
92
94
 
93
95
  #### FUSE setup
94
96
 
95
- The local machine receiving the mounted view needs a working FUSE runtime. The remote Linux host exports files through the sshx protocol and does not need FUSE.
97
+ Each machine that receives a mounted view needs a working FUSE runtime. The remote Linux target needs FUSE for direct local-to-remote workspace mounts; a macOS client needs macFUSE for remote-to-local mounts.
96
98
 
97
- **Linux client**
99
+ **Linux client/target**
98
100
 
99
101
  Install the FUSE 3 userspace tools (the kernel normally already includes the FUSE driver):
100
102
 
@@ -161,7 +163,7 @@ The URL port is the remote port. sshx does not bind `127.0.0.1:<port>`; it binds
161
163
 
162
164
  ### 🏗️ Shared Server Architecture
163
165
 
164
- - Compatible runtime daemons live under `~/.sshx_server/targets/<TargetID>/runtimes/<RuntimeID>` and serve multiple application contexts and sessions.
166
+ - Compatible runtime daemons live under `~/.sshx_server/runtimes/<RuntimeHomeID>` and serve multiple application contexts and sessions. `RuntimeHomeID` is a stable digest of `TargetID` and `RuntimeID`, keeping Unix socket paths safely below platform limits.
165
167
  - Client connects through one hidden multiplexed sidecar SSH channel.
166
168
  - Server manages port sniffing, forwarding state, and command bridge routing centrally.
167
169
  - Clients renew local and remote leases every 5 seconds. A daemon expires a client after 15 seconds without a heartbeat.
@@ -199,9 +201,9 @@ sshx integrate install vscode
199
201
  sshx integrate install cursor
200
202
  ```
201
203
 
202
- The command locates OpenSSH, installs paired `ssh`/`scp` shims backed by the same sshx binary, patches JSONC `remote.SSH.path`, and verifies the complete invocation chain. It is idempotent and rolls back settings and shims on failure; run it again to repair the integration after moving the sshx executable. No restart, integration-specific configuration, or extra binary is required.
204
+ The command locates OpenSSH, installs paired `ssh`/`scp` shims backed by the same sshx binary, patches JSONC `remote.SSH.path`, and verifies the complete invocation chain. It is idempotent and rolls back settings and shims on failure; run it again to repair the integration after moving the sshx executable. npm-managed integrations record a management marker and are refreshed automatically by later `npm install` or `npm update` operations, so the install command does not need to be rerun manually. No restart, integration-specific configuration, or extra binary is required.
203
205
 
204
- Remote-SSH remains the user-visible OpenSSH connection. sshx creates a temporary ControlMaster so bootstrap, sidecar, forwarding, and scp reuse the same authentication. Only a stable `SSHX_CONTEXT_ID` and context-launcher path are injected into application terminals. Probe, unknown, and ambiguous calls are exact passthrough; an incompatible or failed sidecar receives the original bootstrap bytes and preserves normal OpenSSH behavior regardless of `strict`.
206
+ Remote-SSH remains the user-visible OpenSSH connection. Every real SSH session through an integration shim receives the same application context wrapper, while information probes remain exact passthrough. The wrapper exports a stable `SSHX_CONTEXT_ID` and context-launcher path before executing the original remote command; stdin is never inspected or buffered. Each live session owns a sidecar, and temporary ControlMaster sockets let concurrent SSH and scp calls reuse authentication without a resident integration daemon.
205
207
 
206
208
  ### Download Binary
207
209
 
@@ -314,8 +316,9 @@ features:
314
316
  # <host>.<user>.sshx:<remote-port>.
315
317
  autoForward: true
316
318
 
317
- # Lazily mount remote files for local commands. Default: false.
318
- # Requires FUSE only on the local machine.
319
+ # Bidirectional workspace mounts for direct CLI sessions. Default: false.
320
+ # VS Code/Cursor integrations remain remote-to-local only.
321
+ # Requires FUSE on each machine receiving a mount.
319
322
  remoteFs: false
320
323
 
321
324
  commands:
@@ -341,7 +344,7 @@ commands:
341
344
  └─────────────────────────────────┘ └─────────────────────────────────┘
342
345
  ```
343
346
 
344
- 1. **Connection**: `sshx remote` opens a normal SSH session and starts a compatible runtime under `~/.sshx_server/targets/<TargetID>/runtimes/<RuntimeID>`.
347
+ 1. **Connection**: `sshx remote` opens a normal SSH session and starts a compatible runtime under `~/.sshx_server/runtimes/<RuntimeHomeID>`.
345
348
  2. **Sidecar channel**: One hidden SSH channel multiplexes command, port, heartbeat, and optional RemoteFS traffic. ContextID routes VS Code/Cursor terminals to a healthy live session.
346
349
  3. **Port sniffing**: The server reads `/proc/net/tcp*` (Linux) to detect loopback (`127.0.0.1` / `::1`) and wildcard (`0.0.0.0` / `::`) listeners.
347
350
  4. **Forwarding**: Detected ports are forwarded through a single shared local daemon using `ssh -W`.
@@ -406,7 +409,7 @@ sshx/
406
409
 
407
410
  - [x] **v1** — Command bridge (non-interactive), auto port forwarding, domain binding, shared server
408
411
  - [ ] **v2** — Streaming stdin for command bridge, GitHub binary releases, Windows client support
409
- - [x] **remoteFs beta** — Lazy remote-to-local workspace mounting on Linux/macOS clients
412
+ - [x] **remoteFs beta** — Bidirectional direct-CLI workspace mounting, with remote-to-local-only application integrations
410
413
 
411
414
  ---
412
415
 
package/bin/sshx.js CHANGED
@@ -10,9 +10,13 @@ import {
10
10
  writeFileSync,
11
11
  } from "node:fs";
12
12
  import { homedir, tmpdir } from "node:os";
13
- import { dirname, join } from "node:path";
13
+ import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
14
14
  import { fileURLToPath } from "node:url";
15
15
 
16
+ const npmRefreshArgument = "--npm-refresh-integrations";
17
+ const npmManagedMarker = ".npm-managed";
18
+ const integrationProfiles = ["vscode", "cursor"];
19
+
16
20
  const platformByNode = new Map([
17
21
  ["darwin", "darwin"],
18
22
  ["linux", "linux"],
@@ -42,14 +46,33 @@ const cacheRoot =
42
46
  ? join(process.env.LOCALAPPDATA || tmpdir(), "sshx")
43
47
  : join(homedir(), ".cache", "sshx"));
44
48
  const binaryPath = join(cacheRoot, version, binaryName);
49
+ const refreshOnly =
50
+ process.argv.length === 3 && process.argv[2] === npmRefreshArgument;
51
+ const integrationsToRefresh = findNpmIntegrationsToRefresh();
52
+
53
+ // Preserve lazy binary downloads on a first install that has no integrations yet.
54
+ if (refreshOnly && integrationsToRefresh.length === 0) {
55
+ process.exit(0);
56
+ }
45
57
 
46
58
  if (!existsSync(binaryPath)) {
47
59
  await downloadBinary(binaryPath);
48
60
  }
49
61
 
62
+ const nativeEnvironment = {
63
+ ...process.env,
64
+ SSHX_NPM_LAUNCHER: "1",
65
+ };
66
+
67
+ refreshNpmIntegrations(integrationsToRefresh);
68
+
69
+ if (refreshOnly) {
70
+ process.exit(0);
71
+ }
72
+
50
73
  const result = spawnSync(binaryPath, process.argv.slice(2), {
51
74
  stdio: "inherit",
52
- env: process.env,
75
+ env: nativeEnvironment,
53
76
  });
54
77
 
55
78
  if (result.error) {
@@ -59,6 +82,87 @@ if (result.error) {
59
82
 
60
83
  process.exit(result.status ?? 0);
61
84
 
85
+ function findNpmIntegrationsToRefresh() {
86
+ const integrationRoot =
87
+ process.env.SSHX_INTEGRATIONS_DIR ||
88
+ join(homedir(), ".sshx", "integrations");
89
+
90
+ const profiles = [];
91
+
92
+ for (const profile of integrationProfiles) {
93
+ const profileRoot = join(integrationRoot, profile);
94
+ const markerPath = join(profileRoot, npmManagedMarker);
95
+ const descriptor = readIntegrationDescriptor(profileRoot);
96
+ const isMarked = existsSync(markerPath);
97
+ const isLegacyNpmIntegration =
98
+ descriptor !== null && pathIsInside(descriptor.driverPath, cacheRoot);
99
+
100
+ if (!isMarked && !isLegacyNpmIntegration) {
101
+ continue;
102
+ }
103
+ if (
104
+ isMarked &&
105
+ descriptor !== null &&
106
+ samePath(descriptor.driverPath, binaryPath)
107
+ ) {
108
+ continue;
109
+ }
110
+
111
+ profiles.push(profile);
112
+ }
113
+
114
+ return profiles;
115
+ }
116
+
117
+ function refreshNpmIntegrations(profiles) {
118
+ for (const profile of profiles) {
119
+ const refresh = spawnSync(binaryPath, ["integrate", "install", profile], {
120
+ encoding: "utf8",
121
+ env: nativeEnvironment,
122
+ });
123
+ if (refresh.status === 0 && !refresh.error) {
124
+ console.error(`sshx: refreshed ${profile} integration for npm ${version}`);
125
+ continue;
126
+ }
127
+
128
+ const detail =
129
+ refresh.error?.message ||
130
+ refresh.stderr?.trim() ||
131
+ `native installer exited with status ${refresh.status ?? "unknown"}`;
132
+ console.error(
133
+ `sshx: warning: could not refresh ${profile} integration: ${detail}`,
134
+ );
135
+ }
136
+ }
137
+
138
+ function readIntegrationDescriptor(profileRoot) {
139
+ try {
140
+ const value = JSON.parse(
141
+ readFileSync(join(profileRoot, "integration.json"), "utf8"),
142
+ );
143
+ if (typeof value.driverPath === "string" && value.driverPath.length > 0) {
144
+ return value;
145
+ }
146
+ } catch {
147
+ // A marker without a readable descriptor is repaired by the native installer.
148
+ }
149
+ return null;
150
+ }
151
+
152
+ function samePath(left, right) {
153
+ return resolve(left) === resolve(right);
154
+ }
155
+
156
+ function pathIsInside(candidate, rootPath) {
157
+ const child = relative(resolve(rootPath), resolve(candidate));
158
+ return (
159
+ child !== "" &&
160
+ child !== ".." &&
161
+ !child.startsWith(`..${sep}`) &&
162
+ !isAbsolute(child)
163
+ );
164
+ }
165
+
62
166
  async function downloadBinary(destination) {
63
167
  const baseUrl =
64
168
  process.env.SSHX_RELEASE_BASE_URL ||
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hahahhh/sshx",
3
- "version": "0.0.6-rc.0",
3
+ "version": "0.0.6-rc.3",
4
4
  "type": "module",
5
5
  "description": "Transparent SSH enhancement wrapper for OpenSSH",
6
6
  "repository": {
@@ -23,7 +23,8 @@
23
23
  "build": "node scripts/build-native.js",
24
24
  "build:go": "go build -trimpath -ldflags \"-X github.com/xiaot623/sshx/internal/version.Version=${npm_package_version}\" -o sshx ./cmd/sshx",
25
25
  "check": "go vet ./...",
26
- "test": "go test ./...",
26
+ "test": "go test ./... && node --test test/*.test.js",
27
+ "postinstall": "node ./bin/sshx.js --npm-refresh-integrations",
27
28
  "pre_release": "npm version prerelease --preid=rc -m \"chore: pre-release v%s\" && git push origin HEAD --follow-tags",
28
29
  "publish:rc": "npm publish --access public --tag next",
29
30
  "publish:release": "npm publish --access public --tag latest",