@hahahhh/sshx 0.0.6-rc.1 → 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 +16 -13
- 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
|
|
|
@@ -199,7 +201,7 @@ 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
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
|
|
|
@@ -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:
|
|
@@ -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",
|