typescript-virtual-container 1.0.3 → 1.0.5
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/.github/workflows/test-battery.yml +22 -0
- package/CHANGELOG.md +42 -0
- package/README.md +56 -28
- package/package.json +1 -1
- package/src/SSHMimic/client.ts +1 -1
- package/src/SSHMimic/exec.ts +1 -1
- package/src/SSHMimic/executor.ts +201 -0
- package/src/SSHMimic/index.ts +14 -18
- package/src/{VirtualFileSystem.ts → VirtualFileSystem/index.ts} +6 -15
- package/src/{SSHMimic → VirtualShell}/commands/cat.ts +2 -1
- package/src/VirtualShell/commands/command-helpers.ts +135 -0
- package/src/{SSHMimic → VirtualShell}/commands/curl.ts +16 -37
- package/src/VirtualShell/commands/echo.ts +34 -0
- package/src/VirtualShell/commands/env.ts +22 -0
- package/src/VirtualShell/commands/export.ts +38 -0
- package/src/VirtualShell/commands/grep.ts +88 -0
- package/src/{SSHMimic → VirtualShell}/commands/helpers.ts +0 -37
- package/src/VirtualShell/commands/index.ts +327 -0
- package/src/{SSHMimic → VirtualShell}/commands/ls.ts +3 -2
- package/src/{SSHMimic → VirtualShell}/commands/mkdir.ts +6 -1
- package/src/{SSHMimic → VirtualShell}/commands/rm.ts +10 -3
- package/src/VirtualShell/commands/set.ts +73 -0
- package/src/VirtualShell/commands/sh.ts +58 -0
- package/src/{SSHMimic → VirtualShell}/commands/su.ts +3 -3
- package/src/{SSHMimic → VirtualShell}/commands/sudo.ts +16 -26
- package/src/{SSHMimic → VirtualShell}/commands/tree.ts +2 -1
- package/src/VirtualShell/commands/unset.ts +19 -0
- package/src/{SSHMimic → VirtualShell}/commands/wget.ts +23 -6
- package/src/{SSHMimic → VirtualShell}/commands/who.ts +1 -1
- package/src/VirtualShell/index.ts +69 -0
- package/src/{SSHMimic → VirtualShell}/shell.ts +3 -3
- package/src/VirtualShell/shellParser.ts +203 -0
- package/src/index.ts +8 -0
- package/src/standalone.ts +10 -1
- package/src/types/commands.ts +2 -0
- package/src/types/pipeline.ts +23 -0
- package/tests/command-helpers.test.ts +40 -0
- package/tests/helpers.test.ts +1 -1
- package/src/SSHMimic/commands/index.ts +0 -120
- /package/src/{vfs → VirtualFileSystem}/archive.ts +0 -0
- /package/src/{vfs → VirtualFileSystem}/internalTypes.ts +0 -0
- /package/src/{vfs → VirtualFileSystem}/path.ts +0 -0
- /package/src/{vfs → VirtualFileSystem}/snapshot.ts +0 -0
- /package/src/{vfs → VirtualFileSystem}/tree.ts +0 -0
- /package/src/{SSHMimic → VirtualShell}/commands/adduser.ts +0 -0
- /package/src/{SSHMimic → VirtualShell}/commands/cd.ts +0 -0
- /package/src/{SSHMimic → VirtualShell}/commands/clear.ts +0 -0
- /package/src/{SSHMimic → VirtualShell}/commands/deluser.ts +0 -0
- /package/src/{SSHMimic → VirtualShell}/commands/exit.ts +0 -0
- /package/src/{SSHMimic → VirtualShell}/commands/help.ts +0 -0
- /package/src/{SSHMimic → VirtualShell}/commands/hostname.ts +0 -0
- /package/src/{SSHMimic → VirtualShell}/commands/htop.ts +0 -0
- /package/src/{SSHMimic → VirtualShell}/commands/nano.ts +0 -0
- /package/src/{SSHMimic → VirtualShell}/commands/pwd.ts +0 -0
- /package/src/{SSHMimic → VirtualShell}/commands/touch.ts +0 -0
- /package/src/{SSHMimic → VirtualShell}/commands/whoami.ts +0 -0
|
@@ -70,10 +70,32 @@ jobs:
|
|
|
70
70
|
- name: Run lint
|
|
71
71
|
run: bun lint
|
|
72
72
|
|
|
73
|
+
tests:
|
|
74
|
+
needs: setup
|
|
75
|
+
runs-on: ubuntu-latest
|
|
76
|
+
steps:
|
|
77
|
+
- name: Checkout repository
|
|
78
|
+
uses: actions/checkout@v4
|
|
79
|
+
|
|
80
|
+
- name: Setup Bun
|
|
81
|
+
uses: oven-sh/setup-bun@v1
|
|
82
|
+
with:
|
|
83
|
+
bun-version: latest
|
|
84
|
+
|
|
85
|
+
- name: Restore dependencies
|
|
86
|
+
uses: actions/cache@v3
|
|
87
|
+
with:
|
|
88
|
+
path: node_modules
|
|
89
|
+
key: ${{ runner.os }}-bun-${{ hashFiles('bun.lockb') }}
|
|
90
|
+
|
|
91
|
+
- name: Run tests
|
|
92
|
+
run: bun test
|
|
93
|
+
|
|
73
94
|
test-battery:
|
|
74
95
|
needs:
|
|
75
96
|
- typecheck
|
|
76
97
|
- lint
|
|
98
|
+
- tests
|
|
77
99
|
runs-on: ubuntu-latest
|
|
78
100
|
steps:
|
|
79
101
|
- name: Test battery passed
|
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,48 @@ The format is based on Keep a Changelog.
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [1.0.5] - 2026-04-15
|
|
10
|
+
|
|
11
|
+
### Changed
|
|
12
|
+
|
|
13
|
+
- Refactored commands to use shared argument/flag parsing helpers.
|
|
14
|
+
- Improved maintainability and consistency of argument parsing across commands.
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
|
|
18
|
+
- Verified all refactored commands pass existing test cases without regressions.
|
|
19
|
+
|
|
20
|
+
## [1.0.4] - 2026-04-15
|
|
21
|
+
|
|
22
|
+
### Added
|
|
23
|
+
|
|
24
|
+
- Shell pipeline parser and executor with support for:
|
|
25
|
+
- Pipes (`|`)
|
|
26
|
+
- Input redirection (`<`)
|
|
27
|
+
- Output redirection (`>`)
|
|
28
|
+
- Append redirection (`>>`)
|
|
29
|
+
- New built-in commands:
|
|
30
|
+
- `echo`
|
|
31
|
+
- `grep`
|
|
32
|
+
- `set`
|
|
33
|
+
- `env`
|
|
34
|
+
- `export`
|
|
35
|
+
- `unset`
|
|
36
|
+
- `sh` (with `bash` alias)
|
|
37
|
+
- Command stdin support in runtime context so commands can consume piped input.
|
|
38
|
+
|
|
39
|
+
### Changed
|
|
40
|
+
|
|
41
|
+
- Argument parsing now respects quoted strings, including for commands like `sh -c "echo hi"`.
|
|
42
|
+
- `echo` now expands environment variables (`$VAR`) and can read from stdin when no explicit text argument is provided.
|
|
43
|
+
- `grep` now supports stdin input (e.g. `ls | grep ".txt"`) in addition to file operands.
|
|
44
|
+
|
|
45
|
+
### Fixed
|
|
46
|
+
|
|
47
|
+
- Relative file paths in redirections are now resolved from current working directory during pipeline execution.
|
|
48
|
+
- Example fixed behavior: `echo hi > cat.txt` writes to `./cat.txt` in current virtual directory.
|
|
49
|
+
- Pipeline chaining now correctly passes command stdout as stdin to next command.
|
|
50
|
+
|
|
9
51
|
## [1.0.2] - 2026-04-14
|
|
10
52
|
|
|
11
53
|
### Added
|
package/README.md
CHANGED
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
- **Virtual Filesystem**: In-memory filesystem with optional compression, persistence to disk via tar.gz snapshots, and programmatic access.
|
|
40
40
|
- **User Management**: Create, authenticate, and manage virtual users with strict password hashing (scrypt) and sudo-like privilege elevation.
|
|
41
41
|
- **Programmatic Shell API**: Execute shell commands and query filesystem state directly from TypeScript without SSH overhead.
|
|
42
|
-
- **Built-in Commands**: `ls`, `cd`, `pwd`, `cat`, `mkdir`, `touch`, `rm`, `tree`, `whoami`, `hostname`, `who`, `sudo`, `su`, `adduser`, `deluser`, `nano` (text editor), `curl`, `wget`, and
|
|
42
|
+
- **Built-in Commands**: `ls`, `cd`, `pwd`, `cat`, `mkdir`, `touch`, `rm`, `tree`, `whoami`, `hostname`, `who`, `sudo`, `su`, `adduser`, `deluser`, `nano` (text editor), `curl`, `wget`, and a growing set of additional commands. Not everything is implemented yet, and shell compatibility is still being expanded.
|
|
43
43
|
- **Full TypeScript Support**: Complete JSDoc coverage, exported types, and first-class async/await for all operations.
|
|
44
44
|
|
|
45
45
|
## Why This Package
|
|
@@ -140,7 +140,7 @@ ssh.stop();
|
|
|
140
140
|
|
|
141
141
|
## Architecture Overview
|
|
142
142
|
|
|
143
|
-
### Core Components
|
|
143
|
+
<!-- ### Core Components
|
|
144
144
|
|
|
145
145
|
```
|
|
146
146
|
┌─────────────────────────────────────────────┐
|
|
@@ -158,7 +158,7 @@ ssh.stop();
|
|
|
158
158
|
│ Backed by disk
|
|
159
159
|
│ .vfs/mirror.tar.gz
|
|
160
160
|
└──────────────────────────────────┘
|
|
161
|
-
```
|
|
161
|
+
``` -->
|
|
162
162
|
|
|
163
163
|
### Execution Modes
|
|
164
164
|
|
|
@@ -417,6 +417,56 @@ console.log(client.getUsername()); // Username from constructor
|
|
|
417
417
|
|
|
418
418
|
---
|
|
419
419
|
|
|
420
|
+
### VirtualShell
|
|
421
|
+
|
|
422
|
+
Encapsulates shell execution primitives used by the SSH runtime for command dispatch and interactive sessions.
|
|
423
|
+
|
|
424
|
+
#### Constructor
|
|
425
|
+
|
|
426
|
+
```typescript
|
|
427
|
+
new VirtualShell(
|
|
428
|
+
vfs: VirtualFileSystem,
|
|
429
|
+
users: VirtualUserManager,
|
|
430
|
+
hostname: string,
|
|
431
|
+
)
|
|
432
|
+
```
|
|
433
|
+
|
|
434
|
+
- **vfs**: Virtual filesystem instance used by shell commands.
|
|
435
|
+
- **users**: User manager for authentication/session-aware command behavior.
|
|
436
|
+
- **hostname**: Hostname injected into command context and prompt behavior.
|
|
437
|
+
|
|
438
|
+
**Example:**
|
|
439
|
+
|
|
440
|
+
```typescript
|
|
441
|
+
const shell = new VirtualShell(vfs, users, "typescript-vm");
|
|
442
|
+
```
|
|
443
|
+
|
|
444
|
+
#### Methods
|
|
445
|
+
|
|
446
|
+
##### `executeCommand(rawInput: string, authUser: string, cwd: string): void`
|
|
447
|
+
|
|
448
|
+
Runs one command input in shell mode for a given user and working directory.
|
|
449
|
+
|
|
450
|
+
```typescript
|
|
451
|
+
shell.executeCommand("ls -la", "root", "/home/root");
|
|
452
|
+
```
|
|
453
|
+
|
|
454
|
+
##### `startInteractiveSession(stream: ShellStream, authUser: string, sessionId: string | null, remoteAddress: string, terminalSize: { cols: number; rows: number }): void`
|
|
455
|
+
|
|
456
|
+
Starts an interactive shell session over a shell stream.
|
|
457
|
+
|
|
458
|
+
```typescript
|
|
459
|
+
shell.startInteractiveSession(
|
|
460
|
+
stream,
|
|
461
|
+
"root",
|
|
462
|
+
sessionId,
|
|
463
|
+
"127.0.0.1",
|
|
464
|
+
{ cols: 120, rows: 30 },
|
|
465
|
+
);
|
|
466
|
+
```
|
|
467
|
+
|
|
468
|
+
---
|
|
469
|
+
|
|
420
470
|
### VirtualFileSystem
|
|
421
471
|
|
|
422
472
|
In-memory filesystem with optional gzip compression and tar.gz persistence.
|
|
@@ -1006,7 +1056,7 @@ ssh.stop();
|
|
|
1006
1056
|
|
|
1007
1057
|
## Built-in Commands
|
|
1008
1058
|
|
|
1009
|
-
The following commands are available in both SSH shell mode and via `SshClient.exec()
|
|
1059
|
+
The following commands are available in both SSH shell mode and via `SshClient.exec()`. This list is intentionally incomplete: some commands, flags, and edge cases are still missing or only partially compatible with real shells, and that will continue to be worked on.
|
|
1010
1060
|
|
|
1011
1061
|
| Command | Purpose | Notes |
|
|
1012
1062
|
|---------|---------|-------|
|
|
@@ -1125,7 +1175,7 @@ No. It emulates SSH sessions, users, and filesystem behavior in memory. It is id
|
|
|
1125
1175
|
|
|
1126
1176
|
### Can I use this in production?
|
|
1127
1177
|
|
|
1128
|
-
You can use it in production-like automation contexts (sandboxed command runners, test harnesses, training environments), but it is not a security boundary like a real container/VM.
|
|
1178
|
+
You can use it in production-like automation contexts (sandboxed command runners, test harnesses, training environments), but it is not a security boundary like a real container/VM. And at the moment, all commands are not implemented with full fidelity, so it may not be suitable for all production use cases.
|
|
1129
1179
|
|
|
1130
1180
|
### Does data persist between restarts?
|
|
1131
1181
|
|
|
@@ -1262,26 +1312,4 @@ MIT License. See LICENSE file for details.
|
|
|
1262
1312
|
- [ ] Improved shell compatibility for complex piping and redirection
|
|
1263
1313
|
- [ ] Snapshot diff tooling for test assertions
|
|
1264
1314
|
- [ ] Structured event hooks (session open/close, file write, sudo challenge)
|
|
1265
|
-
|
|
1266
|
-
---
|
|
1267
|
-
|
|
1268
|
-
## Changelog
|
|
1269
|
-
|
|
1270
|
-
### v1.0.0 (2026-04-14)
|
|
1271
|
-
|
|
1272
|
-
**Initial Release**
|
|
1273
|
-
|
|
1274
|
-
- ✨ SSH server with password auth
|
|
1275
|
-
- ✨ Virtual filesystem with persistence
|
|
1276
|
-
- ✨ User management & sudoers
|
|
1277
|
-
- ✨ Programmatic `SshClient` API
|
|
1278
|
-
- ✨ 20+ built-in shell commands
|
|
1279
|
-
- ✨ Full TypeScript support & JSDoc coverage
|
|
1280
|
-
- ✨ tar.gz snapshot persistence
|
|
1281
|
-
- ✨ Session & TTY management
|
|
1282
|
-
- ✨ Interactive `nano` editor
|
|
1283
|
-
- ✨ Sudo/su privilege escalation
|
|
1284
|
-
|
|
1285
|
-
---
|
|
1286
|
-
|
|
1287
|
-
**Made with ❤️ for testing, automation, and interactive TypeScript development.**
|
|
1315
|
+
- [ ] WebSocket-based remote shell client (experimental)
|
package/package.json
CHANGED
package/src/SSHMimic/client.ts
CHANGED
package/src/SSHMimic/exec.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { ExecStream } from "../types/streams";
|
|
2
2
|
import type VirtualFileSystem from "../VirtualFileSystem";
|
|
3
|
-
import { runCommand } from "
|
|
3
|
+
import { runCommand } from "../VirtualShell/commands";
|
|
4
4
|
import type { VirtualUserManager } from "./users";
|
|
5
5
|
|
|
6
6
|
function toTtyLines(text: string): string {
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import type { CommandMode, CommandResult } from "../types/commands";
|
|
2
|
+
import type { Pipeline, PipelineCommand } from "../types/pipeline";
|
|
3
|
+
import type VirtualFileSystem from "../VirtualFileSystem";
|
|
4
|
+
import { runCommand as runSingleCommand } from "../VirtualShell/commands";
|
|
5
|
+
import { resolvePath } from "../VirtualShell/commands/helpers";
|
|
6
|
+
import type { VirtualUserManager } from "./users";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Execute a parsed pipeline, chaining commands and handling redirections.
|
|
10
|
+
* Manages stdout/stderr flow between commands and file I/O.
|
|
11
|
+
*/
|
|
12
|
+
export async function executePipeline(
|
|
13
|
+
pipeline: Pipeline,
|
|
14
|
+
authUser: string,
|
|
15
|
+
hostname: string,
|
|
16
|
+
users: VirtualUserManager,
|
|
17
|
+
mode: CommandMode,
|
|
18
|
+
cwd: string,
|
|
19
|
+
vfs: VirtualFileSystem,
|
|
20
|
+
): Promise<CommandResult> {
|
|
21
|
+
if (pipeline.commands.length === 0) {
|
|
22
|
+
return { exitCode: 0 };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (pipeline.commands.length === 1) {
|
|
26
|
+
// Single command with possible redirections
|
|
27
|
+
return executeSingleCommandWithRedirections(
|
|
28
|
+
pipeline.commands[0] as PipelineCommand,
|
|
29
|
+
authUser,
|
|
30
|
+
hostname,
|
|
31
|
+
users,
|
|
32
|
+
mode,
|
|
33
|
+
cwd,
|
|
34
|
+
vfs,
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Multiple commands in a pipeline
|
|
39
|
+
return executePipelineChain(
|
|
40
|
+
pipeline.commands as PipelineCommand[],
|
|
41
|
+
authUser,
|
|
42
|
+
hostname,
|
|
43
|
+
users,
|
|
44
|
+
mode,
|
|
45
|
+
cwd,
|
|
46
|
+
vfs,
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Execute a single command with input/output redirections
|
|
52
|
+
*/
|
|
53
|
+
async function executeSingleCommandWithRedirections(
|
|
54
|
+
cmd: PipelineCommand,
|
|
55
|
+
authUser: string,
|
|
56
|
+
hostname: string,
|
|
57
|
+
users: VirtualUserManager,
|
|
58
|
+
mode: CommandMode,
|
|
59
|
+
cwd: string,
|
|
60
|
+
vfs: VirtualFileSystem,
|
|
61
|
+
): Promise<CommandResult> {
|
|
62
|
+
// Prepare input if input file specified
|
|
63
|
+
let stdin: string | undefined;
|
|
64
|
+
if (cmd.inputFile) {
|
|
65
|
+
const inputPath = resolvePath(cwd, cmd.inputFile);
|
|
66
|
+
try {
|
|
67
|
+
stdin = vfs.readFile(inputPath);
|
|
68
|
+
} catch {
|
|
69
|
+
return {
|
|
70
|
+
stderr: `cat: ${cmd.inputFile}: No such file or directory`,
|
|
71
|
+
exitCode: 1,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Build raw input for the command
|
|
77
|
+
const rawInput = [cmd.name, ...cmd.args].join(" ");
|
|
78
|
+
|
|
79
|
+
// Run the command with potential input
|
|
80
|
+
const result = await runSingleCommand(
|
|
81
|
+
rawInput,
|
|
82
|
+
authUser,
|
|
83
|
+
hostname,
|
|
84
|
+
users,
|
|
85
|
+
mode,
|
|
86
|
+
cwd,
|
|
87
|
+
vfs,
|
|
88
|
+
stdin,
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
// Handle output redirection
|
|
92
|
+
if (cmd.outputFile) {
|
|
93
|
+
const outputPath = resolvePath(cwd, cmd.outputFile);
|
|
94
|
+
const output = result.stdout || "";
|
|
95
|
+
try {
|
|
96
|
+
if (cmd.appendOutput) {
|
|
97
|
+
try {
|
|
98
|
+
const existing = vfs.readFile(outputPath);
|
|
99
|
+
vfs.writeFile(outputPath, existing + output);
|
|
100
|
+
} catch {
|
|
101
|
+
vfs.writeFile(outputPath, output);
|
|
102
|
+
}
|
|
103
|
+
} else {
|
|
104
|
+
vfs.writeFile(outputPath, output);
|
|
105
|
+
}
|
|
106
|
+
return { ...result, stdout: "" };
|
|
107
|
+
} catch {
|
|
108
|
+
return {
|
|
109
|
+
...result,
|
|
110
|
+
stderr: `Failed to write to ${cmd.outputFile}`,
|
|
111
|
+
exitCode: 1,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return result;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Execute a chain of commands connected by pipes
|
|
121
|
+
*/
|
|
122
|
+
async function executePipelineChain(
|
|
123
|
+
commands: PipelineCommand[],
|
|
124
|
+
authUser: string,
|
|
125
|
+
hostname: string,
|
|
126
|
+
users: VirtualUserManager,
|
|
127
|
+
mode: CommandMode,
|
|
128
|
+
cwd: string,
|
|
129
|
+
vfs: VirtualFileSystem,
|
|
130
|
+
): Promise<CommandResult> {
|
|
131
|
+
let currentOutput = "";
|
|
132
|
+
let exitCode = 0;
|
|
133
|
+
|
|
134
|
+
for (let i = 0; i < commands.length; i++) {
|
|
135
|
+
const cmd = commands[i] as PipelineCommand;
|
|
136
|
+
|
|
137
|
+
// Handle input file for first command
|
|
138
|
+
if (i === 0 && cmd.inputFile) {
|
|
139
|
+
const inputPath = resolvePath(cwd, cmd.inputFile);
|
|
140
|
+
try {
|
|
141
|
+
currentOutput = vfs.readFile(inputPath);
|
|
142
|
+
} catch {
|
|
143
|
+
return {
|
|
144
|
+
stderr: `cat: ${cmd.inputFile}: No such file or directory`,
|
|
145
|
+
exitCode: 1,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Build raw input
|
|
151
|
+
const rawInput = [cmd.name, ...cmd.args].join(" ");
|
|
152
|
+
|
|
153
|
+
// Create a modified context that might accept stdin
|
|
154
|
+
// For now, we'll append input as an additional arg for commands that support it
|
|
155
|
+
const result = await runSingleCommand(
|
|
156
|
+
rawInput,
|
|
157
|
+
authUser,
|
|
158
|
+
hostname,
|
|
159
|
+
users,
|
|
160
|
+
mode,
|
|
161
|
+
cwd,
|
|
162
|
+
vfs,
|
|
163
|
+
currentOutput,
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
exitCode = result.exitCode ?? 0;
|
|
167
|
+
|
|
168
|
+
// Handle output redirection (only for last command)
|
|
169
|
+
if (i === commands.length - 1 && cmd.outputFile) {
|
|
170
|
+
const outputPath = resolvePath(cwd, cmd.outputFile);
|
|
171
|
+
const output = result.stdout || "";
|
|
172
|
+
try {
|
|
173
|
+
if (cmd.appendOutput) {
|
|
174
|
+
try {
|
|
175
|
+
const existing = vfs.readFile(outputPath);
|
|
176
|
+
vfs.writeFile(outputPath, existing + output);
|
|
177
|
+
} catch {
|
|
178
|
+
vfs.writeFile(outputPath, output);
|
|
179
|
+
}
|
|
180
|
+
} else {
|
|
181
|
+
vfs.writeFile(outputPath, output);
|
|
182
|
+
}
|
|
183
|
+
currentOutput = "";
|
|
184
|
+
} catch {
|
|
185
|
+
return {
|
|
186
|
+
stderr: `Failed to write to ${cmd.outputFile}`,
|
|
187
|
+
exitCode: 1,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
} else {
|
|
191
|
+
// Pass output to next command
|
|
192
|
+
currentOutput = result.stdout || "";
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (result.stderr && exitCode !== 0) {
|
|
196
|
+
return { stderr: result.stderr, exitCode };
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return { stdout: currentOutput, exitCode };
|
|
201
|
+
}
|
package/src/SSHMimic/index.ts
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
import { randomBytes } from "node:crypto";
|
|
2
2
|
import { Server as SshServer } from "ssh2";
|
|
3
3
|
import VirtualFileSystem from "../VirtualFileSystem";
|
|
4
|
-
import {
|
|
4
|
+
import { VirtualShell } from "../VirtualShell";
|
|
5
5
|
import { loadOrCreateHostKey } from "./hostKey";
|
|
6
|
-
import { startShell } from "./shell";
|
|
7
6
|
import { VirtualUserManager } from "./users";
|
|
8
7
|
|
|
9
8
|
function resolveRootPassword(): string {
|
|
@@ -35,12 +34,13 @@ function resolveAutoSudoForNewUsers(): boolean {
|
|
|
35
34
|
* {@link SshMimic.stop} when your process exits.
|
|
36
35
|
*/
|
|
37
36
|
class SshMimic {
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
37
|
+
port: number;
|
|
38
|
+
hostname: string;
|
|
39
|
+
server: SshServer | null;
|
|
40
|
+
vfs: VirtualFileSystem | null = null;
|
|
41
|
+
users: VirtualUserManager | null = null;
|
|
42
|
+
shell: VirtualShell | null = null;
|
|
43
|
+
basePath: string = ".";
|
|
44
44
|
|
|
45
45
|
/**
|
|
46
46
|
* Creates a new SSH mimic server instance.
|
|
@@ -80,6 +80,8 @@ class SshMimic {
|
|
|
80
80
|
);
|
|
81
81
|
await this.users.initialize();
|
|
82
82
|
|
|
83
|
+
this.shell = new VirtualShell(this.vfs, this.users, this.hostname);
|
|
84
|
+
|
|
83
85
|
this.server = new SshServer(
|
|
84
86
|
{
|
|
85
87
|
hostKeys: [privateKey],
|
|
@@ -148,12 +150,9 @@ class SshMimic {
|
|
|
148
150
|
|
|
149
151
|
session.on("shell", (acceptShell) => {
|
|
150
152
|
const stream = acceptShell();
|
|
151
|
-
|
|
153
|
+
this.shell?.startInteractiveSession(
|
|
152
154
|
stream,
|
|
153
155
|
authUser,
|
|
154
|
-
this.vfs!,
|
|
155
|
-
this.hostname,
|
|
156
|
-
this.users!,
|
|
157
156
|
sessionId,
|
|
158
157
|
remoteAddress,
|
|
159
158
|
terminalSize,
|
|
@@ -161,14 +160,11 @@ class SshMimic {
|
|
|
161
160
|
});
|
|
162
161
|
|
|
163
162
|
session.on("exec", (acceptExec, _rejectExec, info) => {
|
|
164
|
-
const
|
|
165
|
-
|
|
166
|
-
stream,
|
|
163
|
+
const _stream = acceptExec();
|
|
164
|
+
this.shell?.executeCommand(
|
|
167
165
|
info.command.trim(),
|
|
168
166
|
authUser,
|
|
169
|
-
|
|
170
|
-
this.users!,
|
|
171
|
-
this.vfs!,
|
|
167
|
+
`/home/${authUser}`,
|
|
172
168
|
);
|
|
173
169
|
});
|
|
174
170
|
});
|
|
@@ -5,21 +5,12 @@ import type {
|
|
|
5
5
|
RemoveOptions,
|
|
6
6
|
VfsNodeStats,
|
|
7
7
|
WriteFileOptions,
|
|
8
|
-
} from "
|
|
9
|
-
import {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
} from "./
|
|
14
|
-
import type { InternalDirectoryNode, InternalNode } from "./vfs/internalTypes";
|
|
15
|
-
import {
|
|
16
|
-
getNode,
|
|
17
|
-
getParentDirectory,
|
|
18
|
-
normalizePath,
|
|
19
|
-
splitPath,
|
|
20
|
-
} from "./vfs/path";
|
|
21
|
-
import { applySnapshot, createSnapshot } from "./vfs/snapshot";
|
|
22
|
-
import { renderTree } from "./vfs/tree";
|
|
8
|
+
} from "../types/vfs";
|
|
9
|
+
import { archiveExists, createTarBuffer, readSnapshotFromTar } from "./archive";
|
|
10
|
+
import type { InternalDirectoryNode, InternalNode } from "./internalTypes";
|
|
11
|
+
import { getNode, getParentDirectory, normalizePath, splitPath } from "./path";
|
|
12
|
+
import { applySnapshot, createSnapshot } from "./snapshot";
|
|
13
|
+
import { renderTree } from "./tree";
|
|
23
14
|
|
|
24
15
|
/**
|
|
25
16
|
* In-memory virtual filesystem with tar.gz mirror persistence.
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import type { ShellModule } from "../../types/commands";
|
|
2
|
+
import { getArg } from "./command-helpers";
|
|
2
3
|
import { assertPathAccess, resolveReadablePath } from "./helpers";
|
|
3
4
|
|
|
4
5
|
export const catCommand: ShellModule = {
|
|
5
6
|
name: "cat",
|
|
6
7
|
params: ["<file>"],
|
|
7
8
|
run: ({ authUser, vfs, cwd, args }) => {
|
|
8
|
-
const fileArg = args
|
|
9
|
+
const fileArg = getArg(args, 0);
|
|
9
10
|
if (!fileArg) {
|
|
10
11
|
return { stderr: "cat: missing file operand", exitCode: 1 };
|
|
11
12
|
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
type ArgParseOptions = {
|
|
2
|
+
flags?: string[];
|
|
3
|
+
flagsWithValue?: string[];
|
|
4
|
+
};
|
|
5
|
+
|
|
6
|
+
function toFlagList(flags: string | string[]): string[] {
|
|
7
|
+
return Array.isArray(flags) ? flags : [flags];
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function matchFlagToken(
|
|
11
|
+
token: string,
|
|
12
|
+
flag: string,
|
|
13
|
+
): { matched: boolean; inlineValue: string | null } {
|
|
14
|
+
if (token === flag) {
|
|
15
|
+
return { matched: true, inlineValue: null };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const prefix = `${flag}=`;
|
|
19
|
+
if (token.startsWith(prefix)) {
|
|
20
|
+
return { matched: true, inlineValue: token.slice(prefix.length) };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return { matched: false, inlineValue: null };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function collectPositionals(
|
|
27
|
+
args: string[],
|
|
28
|
+
options: ArgParseOptions = {},
|
|
29
|
+
): string[] {
|
|
30
|
+
const boolFlags = new Set(options.flags ?? []);
|
|
31
|
+
const valueFlags = new Set(options.flagsWithValue ?? []);
|
|
32
|
+
const positionals: string[] = [];
|
|
33
|
+
let passthrough = false;
|
|
34
|
+
|
|
35
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
36
|
+
const arg = args[index]!;
|
|
37
|
+
|
|
38
|
+
if (passthrough) {
|
|
39
|
+
positionals.push(arg);
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (arg === "--") {
|
|
44
|
+
passthrough = true;
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
let consumed = false;
|
|
49
|
+
|
|
50
|
+
for (const flag of boolFlags) {
|
|
51
|
+
const { matched } = matchFlagToken(arg, flag);
|
|
52
|
+
if (matched) {
|
|
53
|
+
consumed = true;
|
|
54
|
+
break;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (consumed) {
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
for (const flag of valueFlags) {
|
|
63
|
+
const match = matchFlagToken(arg, flag);
|
|
64
|
+
if (!match.matched) {
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
consumed = true;
|
|
69
|
+
if (match.inlineValue === null && index + 1 < args.length) {
|
|
70
|
+
index += 1;
|
|
71
|
+
}
|
|
72
|
+
break;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (!consumed) {
|
|
76
|
+
positionals.push(arg);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return positionals;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function ifFlag(args: string[], flags: string | string[]): boolean {
|
|
84
|
+
const allFlags = toFlagList(flags);
|
|
85
|
+
|
|
86
|
+
for (const arg of args) {
|
|
87
|
+
for (const flag of allFlags) {
|
|
88
|
+
if (matchFlagToken(arg, flag).matched) {
|
|
89
|
+
return true;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function getFlag(
|
|
98
|
+
args: string[],
|
|
99
|
+
flags: string | string[],
|
|
100
|
+
): string | true | undefined {
|
|
101
|
+
const allFlags = toFlagList(flags);
|
|
102
|
+
|
|
103
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
104
|
+
const arg = args[index]!;
|
|
105
|
+
|
|
106
|
+
for (const flag of allFlags) {
|
|
107
|
+
const match = matchFlagToken(arg, flag);
|
|
108
|
+
if (!match.matched) {
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (match.inlineValue !== null) {
|
|
113
|
+
return match.inlineValue;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const next = args[index + 1];
|
|
117
|
+
if (next !== undefined && next !== "--") {
|
|
118
|
+
return next;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return true;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return undefined;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function getArg(
|
|
129
|
+
args: string[],
|
|
130
|
+
index: number,
|
|
131
|
+
options: ArgParseOptions = {},
|
|
132
|
+
): string | undefined {
|
|
133
|
+
const positionals = collectPositionals(args, options);
|
|
134
|
+
return positionals[index];
|
|
135
|
+
}
|