tmuxes 0.1.8 → 0.1.9
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/.node-version +1 -0
- package/.nvmrc +1 -0
- package/.tmp-npm-cache/_cacache/content-v2/sha512/43/27/5e000b8b9c56a6ccc66f709485499f4304e2cb1982582ba571321c07b3ef56fcabd2c671898cc8003365a0485b6fd8e73e7b17b073cec0f7d1628c1a99df +0 -0
- package/.tmp-npm-cache/_cacache/content-v2/sha512/51/cf/4301295d74559ed494bae160d54d8741077f89faebb311882ac065019246951e7b53f3dcb913793c42b331e14c7070c4810c3cdc27a427d103a7db4614e0 +0 -0
- package/.tmp-npm-cache/_cacache/content-v2/sha512/c3/4d/d68a454a916e74c2617f586fbf770981b33811d667c2547eb0e9fc21938f4ee7e98f1ceee4bde8ad8815b5f6efe21b60eee798837d68f51a3340d7e5bb7a +0 -0
- package/.tmp-npm-cache/_cacache/content-v2/sha512/fe/40/2abfbefc96299e8bf714aa91d62607190ae299e102cf5933db2e2904640d65d25d67dbbb6fa2ddc92a17f00b9dbfdf2e37487f67d96ec36c64a285b59a7d +0 -0
- package/.tmp-npm-cache/_cacache/index-v5/27/fe/81a3de6ce7ae3d1e41a3421de20c5629998c4ee5d0ffe2037630f03b03b2 +4 -0
- package/.tmp-npm-cache/_cacache/index-v5/65/22/dd66711f62681fce09aabb2357a2907b4a0c778ac5227c4baf9603fd86e8 +4 -0
- package/.tmp-npm-cache/_update-notifier-last-checked +0 -0
- package/AGENTS.md +15 -0
- package/CLAUDE.md +3 -0
- package/LICENSE +21 -21
- package/README.en.md +304 -0
- package/README.md +299 -295
- package/SECURITY.md +31 -0
- package/{public → client}/index.html +12 -13
- package/client/package.json +29 -0
- package/client/src/App.tsx +123 -0
- package/client/src/activity.ts +5 -0
- package/client/src/api.ts +130 -0
- package/client/src/attention.tsx +157 -0
- package/client/src/components/FileExplorer.tsx +156 -0
- package/client/src/components/FileViewer.tsx +194 -0
- package/client/src/components/SessionRow.tsx +108 -0
- package/client/src/components/SessionTree.tsx +197 -0
- package/client/src/components/SettingsButton.tsx +122 -0
- package/client/src/components/Sidebar.tsx +96 -0
- package/client/src/components/StatusBanner.tsx +31 -0
- package/client/src/components/TargetGroup.tsx +275 -0
- package/client/src/components/TerminalPanel.tsx +192 -0
- package/client/src/folders.ts +245 -0
- package/client/src/hooks/useTerminal.ts +67 -0
- package/client/src/hooks/useTmuxSocket.ts +65 -0
- package/client/src/i18n.ts +213 -0
- package/client/src/main.tsx +17 -0
- package/client/src/settings.tsx +87 -0
- package/client/src/styles.css +723 -0
- package/client/src/types.ts +93 -0
- package/client/src/util.ts +65 -0
- package/client/tsconfig.json +13 -0
- package/client/vite.config.ts +15 -0
- package/fig/fig1.png +0 -0
- package/package.json +28 -61
- package/scripts/prepack.mjs +35 -0
- package/{bin → server/bin}/tmuxes.js +36 -36
- package/server/package.json +61 -0
- package/server/src/agentHooks.ts +120 -0
- package/server/src/agentOutput.ts +36 -0
- package/server/src/agentState.ts +70 -0
- package/server/src/config.ts +31 -0
- package/server/src/exe.ts +34 -0
- package/server/src/exec.ts +61 -0
- package/server/src/files.ts +330 -0
- package/server/src/foldersStore.ts +114 -0
- package/server/src/index.ts +114 -0
- package/server/src/logger.ts +16 -0
- package/{dist/monitor.js → server/src/monitor.ts} +10 -9
- package/server/src/openBrowser.ts +28 -0
- package/{dist/platform.js → server/src/platform.ts} +4 -5
- package/server/src/rest/router.ts +290 -0
- package/server/src/targetCommand.ts +79 -0
- package/server/src/targets.ts +152 -0
- package/server/src/tmux/builder.ts +198 -0
- package/server/src/tmux/formats.ts +95 -0
- package/server/src/tmux/sessions.ts +204 -0
- package/server/src/validate.ts +79 -0
- package/server/src/windowsSsh.ts +239 -0
- package/server/src/winshell/manager.ts +296 -0
- package/server/src/ws/protocol.ts +15 -0
- package/server/src/ws/sshState.ts +36 -0
- package/server/src/ws/terminalSession.ts +207 -0
- package/server/src/ws/wsServer.ts +153 -0
- package/server/src/wsl.ts +38 -0
- package/server/test/agentHooks.test.ts +66 -0
- package/server/test/agentOutput.test.ts +26 -0
- package/server/test/agentState.test.ts +24 -0
- package/server/test/builder.test.ts +162 -0
- package/server/test/files.test.ts +81 -0
- package/server/test/formats.test.ts +123 -0
- package/server/test/monitor.test.ts +25 -0
- package/server/test/validate.test.ts +71 -0
- package/server/test/wsl.test.ts +18 -0
- package/server/tsconfig.json +9 -0
- package/server/vitest.config.ts +12 -0
- package/start.cmd +30 -0
- package/start.command +20 -0
- package/start.sh +20 -0
- package/tsconfig.base.json +19 -0
- package/dist/agentHooks.js +0 -91
- package/dist/agentHooks.js.map +0 -1
- package/dist/agentOutput.js +0 -30
- package/dist/agentOutput.js.map +0 -1
- package/dist/agentState.js +0 -45
- package/dist/agentState.js.map +0 -1
- package/dist/config.js +0 -32
- package/dist/config.js.map +0 -1
- package/dist/exe.js +0 -37
- package/dist/exe.js.map +0 -1
- package/dist/exec.js +0 -43
- package/dist/exec.js.map +0 -1
- package/dist/files.js +0 -243
- package/dist/files.js.map +0 -1
- package/dist/foldersStore.js +0 -103
- package/dist/foldersStore.js.map +0 -1
- package/dist/index.js +0 -117
- package/dist/index.js.map +0 -1
- package/dist/logger.js +0 -16
- package/dist/logger.js.map +0 -1
- package/dist/monitor.js.map +0 -1
- package/dist/openBrowser.js +0 -31
- package/dist/openBrowser.js.map +0 -1
- package/dist/platform.js.map +0 -1
- package/dist/rest/router.js +0 -190
- package/dist/rest/router.js.map +0 -1
- package/dist/targetCommand.js +0 -41
- package/dist/targetCommand.js.map +0 -1
- package/dist/targets.js +0 -131
- package/dist/targets.js.map +0 -1
- package/dist/tmux/builder.js +0 -173
- package/dist/tmux/builder.js.map +0 -1
- package/dist/tmux/formats.js +0 -61
- package/dist/tmux/formats.js.map +0 -1
- package/dist/tmux/sessions.js +0 -157
- package/dist/tmux/sessions.js.map +0 -1
- package/dist/validate.js +0 -65
- package/dist/validate.js.map +0 -1
- package/dist/winshell/manager.js +0 -267
- package/dist/winshell/manager.js.map +0 -1
- package/dist/ws/protocol.js +0 -4
- package/dist/ws/protocol.js.map +0 -1
- package/dist/ws/sshState.js +0 -35
- package/dist/ws/sshState.js.map +0 -1
- package/dist/ws/terminalSession.js +0 -204
- package/dist/ws/terminalSession.js.map +0 -1
- package/dist/ws/wsServer.js +0 -151
- package/dist/ws/wsServer.js.map +0 -1
- package/dist/wsl.js +0 -35
- package/dist/wsl.js.map +0 -1
- package/public/assets/index-BpVrfoZw.js +0 -44
- package/public/assets/index-D_X5SnGx.css +0 -1
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Allowlist validation. Every value that reaches a spawned tmux/ssh argv is
|
|
3
|
+
* checked here first. We never spawn a shell (argv arrays + shell:false), so
|
|
4
|
+
* this is defense-in-depth, but it also keeps tmux target syntax sane.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
// '.' and ':' are tmux target delimiters (session:window.pane) — forbidden in names.
|
|
8
|
+
const SESSION_NAME_RE = /^[A-Za-z0-9_-]{1,64}$/;
|
|
9
|
+
const HOST_RE = /^[A-Za-z0-9._-]{1,255}$/;
|
|
10
|
+
const USER_RE = /^[A-Za-z0-9._-]{1,64}$/;
|
|
11
|
+
// ssh_config Host aliases: word chars, dots, hyphens (no wildcards/spaces).
|
|
12
|
+
const HOST_ALIAS_RE = /^[A-Za-z0-9._-]{1,255}$/;
|
|
13
|
+
|
|
14
|
+
export function isValidSessionName(name: unknown): name is string {
|
|
15
|
+
return typeof name === 'string' && SESSION_NAME_RE.test(name);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function isValidHost(host: unknown): host is string {
|
|
19
|
+
return typeof host === 'string' && HOST_RE.test(host);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function isValidUser(user: unknown): user is string {
|
|
23
|
+
return typeof user === 'string' && USER_RE.test(user);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function isValidHostAlias(alias: unknown): alias is string {
|
|
27
|
+
return typeof alias === 'string' && HOST_ALIAS_RE.test(alias);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function isValidPort(port: unknown): port is number {
|
|
31
|
+
return (
|
|
32
|
+
typeof port === 'number' &&
|
|
33
|
+
Number.isInteger(port) &&
|
|
34
|
+
port >= 1 &&
|
|
35
|
+
port <= 65535
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Terminal geometry sent by clients. */
|
|
40
|
+
export function isValidDimension(n: unknown): n is number {
|
|
41
|
+
return typeof n === 'number' && Number.isInteger(n) && n >= 1 && n <= 1000;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface HostSpec {
|
|
45
|
+
user?: string;
|
|
46
|
+
host: string;
|
|
47
|
+
port?: number;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Parse a "user@host:port" spec (used by the TMUXES_HOSTS env override).
|
|
52
|
+
* Returns null if any component is invalid.
|
|
53
|
+
*/
|
|
54
|
+
export function parseHostSpec(spec: string): HostSpec | null {
|
|
55
|
+
const trimmed = spec.trim();
|
|
56
|
+
if (!trimmed) return null;
|
|
57
|
+
|
|
58
|
+
let user: string | undefined;
|
|
59
|
+
let rest = trimmed;
|
|
60
|
+
const at = rest.indexOf('@');
|
|
61
|
+
if (at !== -1) {
|
|
62
|
+
user = rest.slice(0, at);
|
|
63
|
+
rest = rest.slice(at + 1);
|
|
64
|
+
if (!isValidUser(user)) return null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
let port: number | undefined;
|
|
68
|
+
const colon = rest.indexOf(':');
|
|
69
|
+
if (colon !== -1) {
|
|
70
|
+
const portStr = rest.slice(colon + 1);
|
|
71
|
+
rest = rest.slice(0, colon);
|
|
72
|
+
const parsed = Number(portStr);
|
|
73
|
+
if (!isValidPort(parsed)) return null;
|
|
74
|
+
port = parsed;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (!isValidHost(rest)) return null;
|
|
78
|
+
return { user, host: rest, port };
|
|
79
|
+
}
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import type { CommandResult, RunOptions } from './exec.js';
|
|
4
|
+
import { config } from './config.js';
|
|
5
|
+
import { sshDestination, type Target } from './targets.js';
|
|
6
|
+
import { sshClientArgs } from './tmux/builder.js';
|
|
7
|
+
|
|
8
|
+
interface RemoteCommand {
|
|
9
|
+
command: string;
|
|
10
|
+
input?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface PendingCommand {
|
|
14
|
+
id: string;
|
|
15
|
+
resolve: (result: CommandResult) => void;
|
|
16
|
+
timer?: NodeJS.Timeout;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const SENSITIVE_SSH_OPTIONS = new Set([
|
|
20
|
+
'-B',
|
|
21
|
+
'-b',
|
|
22
|
+
'-c',
|
|
23
|
+
'-D',
|
|
24
|
+
'-E',
|
|
25
|
+
'-e',
|
|
26
|
+
'-F',
|
|
27
|
+
'-I',
|
|
28
|
+
'-i',
|
|
29
|
+
'-J',
|
|
30
|
+
'-L',
|
|
31
|
+
'-l',
|
|
32
|
+
'-m',
|
|
33
|
+
'-O',
|
|
34
|
+
'-o',
|
|
35
|
+
'-p',
|
|
36
|
+
'-Q',
|
|
37
|
+
'-R',
|
|
38
|
+
'-S',
|
|
39
|
+
'-W',
|
|
40
|
+
'-w',
|
|
41
|
+
]);
|
|
42
|
+
|
|
43
|
+
const sessions = new Map<string, WindowsSshSession>();
|
|
44
|
+
|
|
45
|
+
function targetKey(target: Target): string {
|
|
46
|
+
return `${sshDestination(target)}:${target.port ?? 22}:${target.id}`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function splitSshArgs(args: string[]): { connectArgs: string[]; remoteArgs: string[] } {
|
|
50
|
+
for (let i = 0; i < args.length; i++) {
|
|
51
|
+
const arg = args[i];
|
|
52
|
+
if (SENSITIVE_SSH_OPTIONS.has(arg)) {
|
|
53
|
+
i++;
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
if (arg.startsWith('-')) continue;
|
|
57
|
+
return { connectArgs: args.slice(0, i + 1), remoteArgs: args.slice(i + 1) };
|
|
58
|
+
}
|
|
59
|
+
return { connectArgs: args, remoteArgs: [] };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function commandFromSshArgv(file: string, args: string[], input?: string): RemoteCommand {
|
|
63
|
+
if (file !== 'ssh') return { command: '', input };
|
|
64
|
+
const { remoteArgs } = splitSshArgs(args);
|
|
65
|
+
return { command: remoteArgs.join(' '), input };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function connectArgsFor(target: Target): string[] {
|
|
69
|
+
const argv = sshClientArgs(target, {
|
|
70
|
+
batchMode: true,
|
|
71
|
+
connectTimeout: config.ssh.connectTimeoutMgmt,
|
|
72
|
+
multiplex: false,
|
|
73
|
+
});
|
|
74
|
+
const { connectArgs } = splitSshArgs(argv.slice(1));
|
|
75
|
+
return ['-T', ...connectArgs, 'sh'];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function encodeInput(input: string): string {
|
|
79
|
+
return Buffer.from(input, 'utf8').toString('base64').replace(/(.{76})/g, '$1\n');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function decodePayload(payload: string): string {
|
|
83
|
+
return Buffer.from(payload.replace(/\s+/g, ''), 'base64').toString('utf8');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function scriptFor(id: string, command: string, input?: string): string {
|
|
87
|
+
const inputMarker = `__TMUXES_INPUT_${id}__`;
|
|
88
|
+
const begin = `__TMUXES_BEGIN_${id}__`;
|
|
89
|
+
const stderr = `__TMUXES_STDERR_${id}__`;
|
|
90
|
+
const end = `__TMUXES_END_${id}__`;
|
|
91
|
+
const run = input === undefined
|
|
92
|
+
? `( ${command} )`
|
|
93
|
+
: `base64 -d <<'${inputMarker}' | ( ${command} )\n${encodeInput(input)}\n${inputMarker}`;
|
|
94
|
+
|
|
95
|
+
return [
|
|
96
|
+
`__tmuxes_out=$(mktemp "\${TMPDIR:-/tmp}/tmuxes-out.XXXXXX") || exit 125`,
|
|
97
|
+
`__tmuxes_err=$(mktemp "\${TMPDIR:-/tmp}/tmuxes-err.XXXXXX") || { rm -f "$__tmuxes_out"; exit 125; }`,
|
|
98
|
+
`{ ${run}\n} > "$__tmuxes_out" 2> "$__tmuxes_err"`,
|
|
99
|
+
`__tmuxes_code=$?`,
|
|
100
|
+
`printf '\\n${begin}\\n'`,
|
|
101
|
+
`printf '%s\\n' "$__tmuxes_code"`,
|
|
102
|
+
`base64 < "$__tmuxes_out"`,
|
|
103
|
+
`printf '\\n${stderr}\\n'`,
|
|
104
|
+
`base64 < "$__tmuxes_err"`,
|
|
105
|
+
`printf '\\n${end}\\n'`,
|
|
106
|
+
`rm -f "$__tmuxes_out" "$__tmuxes_err"`,
|
|
107
|
+
].join('\n') + '\n';
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
class WindowsSshSession {
|
|
111
|
+
private readonly child: ChildProcessWithoutNullStreams;
|
|
112
|
+
private queue: Promise<void> = Promise.resolve();
|
|
113
|
+
private stdout = '';
|
|
114
|
+
private sshStderr = '';
|
|
115
|
+
private closed = false;
|
|
116
|
+
private pending?: PendingCommand;
|
|
117
|
+
|
|
118
|
+
constructor(
|
|
119
|
+
private readonly key: string,
|
|
120
|
+
target: Target,
|
|
121
|
+
) {
|
|
122
|
+
this.child = spawn('ssh', connectArgsFor(target), { shell: false });
|
|
123
|
+
this.child.stdout.on('data', (chunk: Buffer) => this.onStdout(chunk.toString('utf8')));
|
|
124
|
+
this.child.stderr.on('data', (chunk: Buffer) => {
|
|
125
|
+
this.sshStderr += chunk.toString('utf8');
|
|
126
|
+
});
|
|
127
|
+
this.child.on('error', (err) => this.finishPending(null, null, err.message));
|
|
128
|
+
this.child.on('close', (code, signal) => {
|
|
129
|
+
this.closed = true;
|
|
130
|
+
if (sessions.get(this.key) === this) sessions.delete(this.key);
|
|
131
|
+
this.finishPending(code, signal, this.sshStderr || 'ssh connection closed');
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
dispose(): void {
|
|
136
|
+
this.closed = true;
|
|
137
|
+
if (sessions.get(this.key) === this) sessions.delete(this.key);
|
|
138
|
+
try {
|
|
139
|
+
this.child.kill('SIGKILL');
|
|
140
|
+
} catch {
|
|
141
|
+
/* already gone */
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
run(command: string, input: string | undefined, opts: RunOptions): Promise<CommandResult> {
|
|
146
|
+
const next = this.queue
|
|
147
|
+
.catch(() => {
|
|
148
|
+
/* keep later commands moving */
|
|
149
|
+
})
|
|
150
|
+
.then(() => this.runNow(command, input, opts));
|
|
151
|
+
this.queue = next.then(
|
|
152
|
+
() => undefined,
|
|
153
|
+
() => undefined,
|
|
154
|
+
);
|
|
155
|
+
return next;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
private runNow(command: string, input: string | undefined, opts: RunOptions): Promise<CommandResult> {
|
|
159
|
+
if (this.closed) return Promise.resolve({ code: 255, signal: null, stdout: '', stderr: 'ssh connection closed' });
|
|
160
|
+
if (!command) return Promise.resolve({ code: 255, signal: null, stdout: '', stderr: 'missing remote command' });
|
|
161
|
+
|
|
162
|
+
const id = randomUUID().replace(/-/g, '');
|
|
163
|
+
return new Promise((resolve) => {
|
|
164
|
+
const pending: PendingCommand = { id, resolve };
|
|
165
|
+
this.pending = pending;
|
|
166
|
+
if (opts.timeoutMs) {
|
|
167
|
+
pending.timer = setTimeout(() => {
|
|
168
|
+
this.resolvePending({ code: null, signal: 'SIGKILL', stdout: '', stderr: 'ssh command timed out' });
|
|
169
|
+
this.dispose();
|
|
170
|
+
}, opts.timeoutMs);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
this.child.stdin.write(scriptFor(id, command, input), (err) => {
|
|
174
|
+
if (err) this.finishPending(null, null, err.message);
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
private onStdout(chunk: string): void {
|
|
180
|
+
this.stdout += chunk;
|
|
181
|
+
const pending = this.pending;
|
|
182
|
+
if (!pending) return;
|
|
183
|
+
|
|
184
|
+
const begin = `__TMUXES_BEGIN_${pending.id}__\n`;
|
|
185
|
+
const stderr = `\n__TMUXES_STDERR_${pending.id}__\n`;
|
|
186
|
+
const end = `\n__TMUXES_END_${pending.id}__`;
|
|
187
|
+
const beginIndex = this.stdout.indexOf(begin);
|
|
188
|
+
if (beginIndex < 0) return;
|
|
189
|
+
const stderrIndex = this.stdout.indexOf(stderr, beginIndex + begin.length);
|
|
190
|
+
if (stderrIndex < 0) return;
|
|
191
|
+
const endIndex = this.stdout.indexOf(end, stderrIndex + stderr.length);
|
|
192
|
+
if (endIndex < 0) return;
|
|
193
|
+
|
|
194
|
+
const stdoutStart = beginIndex + begin.length;
|
|
195
|
+
const codeLineEnd = this.stdout.indexOf('\n', stdoutStart);
|
|
196
|
+
if (codeLineEnd < 0 || codeLineEnd > stderrIndex) return;
|
|
197
|
+
|
|
198
|
+
const code = Number(this.stdout.slice(stdoutStart, codeLineEnd)) || 0;
|
|
199
|
+
const out = decodePayload(this.stdout.slice(codeLineEnd + 1, stderrIndex));
|
|
200
|
+
const err = decodePayload(this.stdout.slice(stderrIndex + stderr.length, endIndex));
|
|
201
|
+
this.stdout = this.stdout.slice(endIndex + end.length);
|
|
202
|
+
this.resolvePending({ code, signal: null, stdout: out, stderr: err });
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
private resolvePending(result: CommandResult): void {
|
|
206
|
+
const pending = this.pending;
|
|
207
|
+
if (!pending) return;
|
|
208
|
+
this.pending = undefined;
|
|
209
|
+
if (pending.timer) clearTimeout(pending.timer);
|
|
210
|
+
pending.resolve(result);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
private finishPending(code: number | null, signal: NodeJS.Signals | null, stderr: string): void {
|
|
214
|
+
this.resolvePending({ code, signal, stdout: '', stderr });
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function sessionFor(target: Target): WindowsSshSession {
|
|
219
|
+
const key = targetKey(target);
|
|
220
|
+
const existing = sessions.get(key);
|
|
221
|
+
if (existing) return existing;
|
|
222
|
+
const session = new WindowsSshSession(key, target);
|
|
223
|
+
sessions.set(key, session);
|
|
224
|
+
return session;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export async function runWindowsSshCommand(
|
|
228
|
+
target: Target,
|
|
229
|
+
file: string,
|
|
230
|
+
args: string[],
|
|
231
|
+
opts: RunOptions,
|
|
232
|
+
): Promise<CommandResult> {
|
|
233
|
+
const { command, input } = commandFromSshArgv(file, args, opts.input);
|
|
234
|
+
return sessionFor(target).run(command, input, opts);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export function dropWindowsSshSession(target: Target): void {
|
|
238
|
+
sessions.get(targetKey(target))?.dispose();
|
|
239
|
+
}
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
import * as pty from 'node-pty';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { isWindows } from '../platform.js';
|
|
6
|
+
import { log } from '../logger.js';
|
|
7
|
+
import type { ServerControl } from '../ws/protocol.js';
|
|
8
|
+
import type { SessionInfo, WindowInfo } from '../tmux/formats.js';
|
|
9
|
+
|
|
10
|
+
/** A definition of a launchable shell. */
|
|
11
|
+
export interface ShellDef {
|
|
12
|
+
id: string;
|
|
13
|
+
label: string;
|
|
14
|
+
file: string;
|
|
15
|
+
args: string[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** What the manager needs from an attached WebSocket — kept abstract so the
|
|
19
|
+
* session logic is testable without a real socket. */
|
|
20
|
+
export interface ShellClient {
|
|
21
|
+
id: number;
|
|
22
|
+
sendBinary(buf: Buffer): void;
|
|
23
|
+
sendControl(msg: ServerControl): void;
|
|
24
|
+
isOpen(): boolean;
|
|
25
|
+
close(code: number, reason: string): void;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const SCROLLBACK_CAP = 256 * 1024; // bytes of history replayed to a new client
|
|
29
|
+
|
|
30
|
+
function nowEpoch(): number {
|
|
31
|
+
return Math.floor(Date.now() / 1000);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** One persistent native shell (its own pty), shared by 0..N attached clients.
|
|
35
|
+
* Survives client disconnects (the pty lives until killed or it exits), which
|
|
36
|
+
* is what gives "refresh / reconnect / multi-tab" persistence. */
|
|
37
|
+
export class WinShellSession {
|
|
38
|
+
readonly created = nowEpoch();
|
|
39
|
+
private clients = new Set<ShellClient>();
|
|
40
|
+
private scroll: Buffer[] = [];
|
|
41
|
+
private scrollBytes = 0;
|
|
42
|
+
private disposed = false;
|
|
43
|
+
/** epoch seconds of last pty output — drives idle/attention detection */
|
|
44
|
+
private lastActivity = nowEpoch();
|
|
45
|
+
|
|
46
|
+
constructor(
|
|
47
|
+
public name: string,
|
|
48
|
+
readonly shell: ShellDef,
|
|
49
|
+
private readonly ptyProc: pty.IPty,
|
|
50
|
+
private readonly onClosed: (name: string) => void,
|
|
51
|
+
) {
|
|
52
|
+
this.ptyProc.onData((d) => this.onData(d));
|
|
53
|
+
this.ptyProc.onExit(({ exitCode }) => this.onExit(exitCode));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
get attached(): boolean {
|
|
57
|
+
return this.clients.size > 0;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
private onData(data: string): void {
|
|
61
|
+
this.lastActivity = nowEpoch();
|
|
62
|
+
const buf = Buffer.from(data, 'utf8');
|
|
63
|
+
this.scroll.push(buf);
|
|
64
|
+
this.scrollBytes += buf.length;
|
|
65
|
+
while (this.scrollBytes > SCROLLBACK_CAP && this.scroll.length > 1) {
|
|
66
|
+
this.scrollBytes -= this.scroll.shift()!.length;
|
|
67
|
+
}
|
|
68
|
+
for (const c of this.clients) if (c.isOpen()) c.sendBinary(buf);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
private onExit(code: number | null): void {
|
|
72
|
+
if (this.disposed) return;
|
|
73
|
+
this.disposed = true;
|
|
74
|
+
for (const c of this.clients) {
|
|
75
|
+
c.sendControl({ type: 'exit', code });
|
|
76
|
+
c.close(1000, 'shell exited');
|
|
77
|
+
}
|
|
78
|
+
this.clients.clear();
|
|
79
|
+
this.onClosed(this.name);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Add a client: replay history, then it's live. Caller sends `ready` after. */
|
|
83
|
+
attach(client: ShellClient): void {
|
|
84
|
+
if (this.scroll.length) client.sendBinary(Buffer.concat(this.scroll));
|
|
85
|
+
this.clients.add(client);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
detach(client: ShellClient): void {
|
|
89
|
+
this.clients.delete(client); // pty stays alive — persistence across reconnects
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
write(data: string): void {
|
|
93
|
+
if (!this.disposed) this.ptyProc.write(data);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
resize(cols: number, rows: number): void {
|
|
97
|
+
try {
|
|
98
|
+
this.ptyProc.resize(cols, rows);
|
|
99
|
+
} catch {
|
|
100
|
+
/* pty may have exited */
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
info(): SessionInfo {
|
|
105
|
+
return {
|
|
106
|
+
name: this.name,
|
|
107
|
+
windows: 1,
|
|
108
|
+
attached: this.attached,
|
|
109
|
+
created: this.created,
|
|
110
|
+
lastActivity: this.lastActivity,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
dispose(): void {
|
|
115
|
+
if (this.disposed) {
|
|
116
|
+
this.onClosed(this.name);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
this.disposed = true;
|
|
120
|
+
try {
|
|
121
|
+
this.ptyProc.kill();
|
|
122
|
+
} catch {
|
|
123
|
+
/* already gone */
|
|
124
|
+
}
|
|
125
|
+
for (const c of this.clients) c.close(1000, 'killed');
|
|
126
|
+
this.clients.clear();
|
|
127
|
+
this.onClosed(this.name);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const NAME_RE = /^[A-Za-z0-9_-]{1,64}$/;
|
|
132
|
+
|
|
133
|
+
/** Owns all native shell sessions for the local Windows machine. */
|
|
134
|
+
export class WinShellManager {
|
|
135
|
+
private sessions = new Map<string, WinShellSession>();
|
|
136
|
+
private shells: ShellDef[];
|
|
137
|
+
|
|
138
|
+
constructor(shells?: ShellDef[]) {
|
|
139
|
+
this.shells = shells ?? detectShells();
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
listShells(): { id: string; label: string }[] {
|
|
143
|
+
return this.shells.map((s) => ({ id: s.id, label: s.label }));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
private resolveShell(shellId?: string): ShellDef {
|
|
147
|
+
if (shellId) {
|
|
148
|
+
const found = this.shells.find((s) => s.id === shellId);
|
|
149
|
+
if (found) return found;
|
|
150
|
+
}
|
|
151
|
+
if (this.shells.length === 0) throw new Error('no shell available');
|
|
152
|
+
return this.shells[0];
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
list(): SessionInfo[] {
|
|
156
|
+
return [...this.sessions.values()]
|
|
157
|
+
.map((s) => s.info())
|
|
158
|
+
.sort((a, b) => a.created - b.created);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
windows(name: string): WindowInfo[] {
|
|
162
|
+
const s = this.sessions.get(name);
|
|
163
|
+
if (!s) return [];
|
|
164
|
+
return [{ index: 0, name: s.shell.label, panes: 1, active: true }];
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
has(name: string): boolean {
|
|
168
|
+
return this.sessions.has(name);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
private autoName(shell: ShellDef): string {
|
|
172
|
+
for (let i = 1; i < 10000; i++) {
|
|
173
|
+
const candidate = `${shell.id}-${i}`;
|
|
174
|
+
if (!this.sessions.has(candidate)) return candidate;
|
|
175
|
+
}
|
|
176
|
+
return `${shell.id}-${nowEpoch()}`;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Create a new shell session. Returns the (possibly auto-assigned) name. */
|
|
180
|
+
create(opts: { name?: string; shellId?: string; command?: string; cols?: number; rows?: number }): {
|
|
181
|
+
name: string;
|
|
182
|
+
} {
|
|
183
|
+
const shell = this.resolveShell(opts.shellId);
|
|
184
|
+
const name = opts.name ?? this.autoName(shell);
|
|
185
|
+
if (!NAME_RE.test(name)) throw new ManagerError(400, 'invalid session name');
|
|
186
|
+
if (this.sessions.has(name)) throw new ManagerError(409, `session "${name}" already exists`);
|
|
187
|
+
|
|
188
|
+
const cols = opts.cols ?? 80;
|
|
189
|
+
const rows = opts.rows ?? 24;
|
|
190
|
+
let proc: pty.IPty;
|
|
191
|
+
try {
|
|
192
|
+
proc = pty.spawn(shell.file, shell.args, {
|
|
193
|
+
name: 'xterm-256color',
|
|
194
|
+
cols,
|
|
195
|
+
rows,
|
|
196
|
+
cwd: homedir(),
|
|
197
|
+
env: { ...process.env, TERM: 'xterm-256color' },
|
|
198
|
+
});
|
|
199
|
+
} catch (e) {
|
|
200
|
+
throw new ManagerError(502, `failed to start ${shell.label}: ${(e as Error).message}`);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const session = new WinShellSession(name, shell, proc, (n) => this.sessions.delete(n));
|
|
204
|
+
this.sessions.set(name, session);
|
|
205
|
+
if (opts.command && opts.command.length > 0) session.write(`${opts.command}\r`);
|
|
206
|
+
log.info(`winshell created ${name} (${shell.label})`);
|
|
207
|
+
return { name };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
rename(oldName: string, newName: string): void {
|
|
211
|
+
if (!NAME_RE.test(newName)) throw new ManagerError(400, 'invalid new session name');
|
|
212
|
+
const s = this.sessions.get(oldName);
|
|
213
|
+
if (!s) throw new ManagerError(404, `session "${oldName}" not found`);
|
|
214
|
+
if (this.sessions.has(newName)) throw new ManagerError(409, `session "${newName}" already exists`);
|
|
215
|
+
// Re-key the same live instance (its pty handlers read `this.name`).
|
|
216
|
+
this.sessions.delete(oldName);
|
|
217
|
+
s.name = newName;
|
|
218
|
+
this.sessions.set(newName, s);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
kill(name: string): void {
|
|
222
|
+
const s = this.sessions.get(name);
|
|
223
|
+
if (!s) throw new ManagerError(404, `session "${name}" not found`);
|
|
224
|
+
s.dispose();
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Attach a client to an existing session, creating it (default shell) if missing. */
|
|
228
|
+
attachOrCreate(name: string, cols: number, rows: number, client: ShellClient): WinShellSession {
|
|
229
|
+
let s = this.sessions.get(name);
|
|
230
|
+
if (!s) {
|
|
231
|
+
this.create({ name, cols, rows });
|
|
232
|
+
s = this.sessions.get(name)!;
|
|
233
|
+
} else {
|
|
234
|
+
s.resize(cols, rows);
|
|
235
|
+
}
|
|
236
|
+
s.attach(client);
|
|
237
|
+
return s;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
disposeAll(): void {
|
|
241
|
+
for (const s of [...this.sessions.values()]) s.dispose();
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** Manager error carrying an HTTP status for the router. */
|
|
246
|
+
export class ManagerError extends Error {
|
|
247
|
+
constructor(
|
|
248
|
+
public status: number,
|
|
249
|
+
message: string,
|
|
250
|
+
) {
|
|
251
|
+
super(message);
|
|
252
|
+
this.name = 'ManagerError';
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** Detect launchable shells for this machine. Windows: PowerShell 7 (if on
|
|
257
|
+
* PATH) → Windows PowerShell → cmd → Git Bash. Otherwise (test/fake): the
|
|
258
|
+
* user's shell, so the whole path can be exercised on Linux. */
|
|
259
|
+
export function detectShells(): ShellDef[] {
|
|
260
|
+
if (!isWindows) {
|
|
261
|
+
const file = process.env.SHELL || '/bin/bash';
|
|
262
|
+
return [{ id: 'shell', label: file.split('/').pop() || 'shell', file, args: ['-i'] }];
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const shells: ShellDef[] = [];
|
|
266
|
+
const sysRoot = process.env.SystemRoot || process.env.windir || 'C:\\Windows';
|
|
267
|
+
|
|
268
|
+
const pwsh = findOnPath('pwsh.exe');
|
|
269
|
+
if (pwsh) shells.push({ id: 'pwsh', label: 'PowerShell 7', file: pwsh, args: ['-NoLogo'] });
|
|
270
|
+
|
|
271
|
+
const winPs = join(sysRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe');
|
|
272
|
+
if (existsSync(winPs)) shells.push({ id: 'powershell', label: 'Windows PowerShell', file: winPs, args: ['-NoLogo'] });
|
|
273
|
+
|
|
274
|
+
const cmd = join(sysRoot, 'System32', 'cmd.exe');
|
|
275
|
+
if (existsSync(cmd)) shells.push({ id: 'cmd', label: 'Command Prompt', file: cmd, args: [] });
|
|
276
|
+
|
|
277
|
+
const gitBash = 'C:\\Program Files\\Git\\bin\\bash.exe';
|
|
278
|
+
if (existsSync(gitBash)) shells.push({ id: 'gitbash', label: 'Git Bash', file: gitBash, args: ['-i', '-l'] });
|
|
279
|
+
|
|
280
|
+
// Last-resort fallback so the target is never shell-less.
|
|
281
|
+
if (shells.length === 0) shells.push({ id: 'cmd', label: 'Command Prompt', file: 'cmd.exe', args: [] });
|
|
282
|
+
return shells;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function findOnPath(exe: string): string | null {
|
|
286
|
+
const dirs = (process.env.PATH || '').split(isWindows ? ';' : ':');
|
|
287
|
+
for (const dir of dirs) {
|
|
288
|
+
if (!dir) continue;
|
|
289
|
+
const candidate = join(dir, exe);
|
|
290
|
+
if (existsSync(candidate)) return candidate;
|
|
291
|
+
}
|
|
292
|
+
return null;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** Singleton used by the REST and WS layers. */
|
|
296
|
+
export const winShell = new WinShellManager();
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** Shared WebSocket control-message types. Terminal bytes travel as BINARY
|
|
2
|
+
* frames; everything below travels as TEXT (JSON) frames. */
|
|
3
|
+
|
|
4
|
+
export type SshState = 'hostkey' | 'authfail' | 'refused' | 'timeout';
|
|
5
|
+
|
|
6
|
+
export type ClientControl =
|
|
7
|
+
| { type: 'resize'; cols: number; rows: number }
|
|
8
|
+
| { type: 'ping' };
|
|
9
|
+
|
|
10
|
+
export type ServerControl =
|
|
11
|
+
| { type: 'ready'; target: string; session: string }
|
|
12
|
+
| { type: 'ssh'; state: SshState; message: string }
|
|
13
|
+
| { type: 'error'; message: string }
|
|
14
|
+
| { type: 'exit'; code: number | null }
|
|
15
|
+
| { type: 'pong' };
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { SshState } from './protocol.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Classify ssh failure/prompt states from PTY output so the UI can show
|
|
5
|
+
* something better than a black screen. Best-effort string matching on the
|
|
6
|
+
* raw ssh client output.
|
|
7
|
+
*/
|
|
8
|
+
const MATCHERS: { state: SshState; re: RegExp; message: string }[] = [
|
|
9
|
+
{
|
|
10
|
+
state: 'hostkey',
|
|
11
|
+
re: /authenticity of host|fingerprint|known_hosts|REMOTE HOST IDENTIFICATION HAS CHANGED/i,
|
|
12
|
+
message: 'Unknown or changed host key — verify the host in a regular terminal first.',
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
state: 'authfail',
|
|
16
|
+
re: /permission denied|too many authentication failures|no such identity|authentication failed/i,
|
|
17
|
+
message: 'SSH authentication failed — check your keys / ssh-agent.',
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
state: 'refused',
|
|
21
|
+
re: /connection refused|could not resolve hostname|name or service not known|no route to host/i,
|
|
22
|
+
message: 'Could not connect to the host (refused / unresolved).',
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
state: 'timeout',
|
|
26
|
+
re: /connection timed out|operation timed out|timed out waiting/i,
|
|
27
|
+
message: 'Connection timed out.',
|
|
28
|
+
},
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
export function classifySsh(text: string): { state: SshState; message: string } | null {
|
|
32
|
+
for (const m of MATCHERS) {
|
|
33
|
+
if (m.re.test(text)) return { state: m.state, message: m.message };
|
|
34
|
+
}
|
|
35
|
+
return null;
|
|
36
|
+
}
|