@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.
- package/README.md +19 -16
- package/bin/sshx.js +106 -2
- 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
|
-
- 📁 **
|
|
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
|
-

|
|
29
|
+

|
|
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
|
-
### 📁
|
|
71
|
+
### 📁 Bidirectional Workspace Mount (opt-in, beta)
|
|
72
72
|
|
|
73
|
-
Set `features.remoteFs: true` to
|
|
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
|
-
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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/
|
|
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.
|
|
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
|
-
#
|
|
318
|
-
#
|
|
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/
|
|
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** —
|
|
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:
|
|
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.
|
|
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",
|