start-command 0.9.0 → 0.11.0
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/release.yml +34 -0
- package/CHANGELOG.md +46 -0
- package/README.md +60 -15
- package/REQUIREMENTS.md +48 -2
- package/experiments/user-isolation-research.md +83 -0
- package/package.json +1 -1
- package/src/bin/cli.js +137 -44
- package/src/lib/args-parser.js +95 -3
- package/src/lib/isolation.js +193 -41
- package/src/lib/user-manager.js +429 -0
- package/test/args-parser.test.js +237 -1
- package/test/isolation.test.js +73 -0
- package/test/ssh-integration.test.js +328 -0
- package/test/user-manager.test.js +286 -0
package/test/isolation.test.js
CHANGED
|
@@ -16,6 +16,39 @@ const {
|
|
|
16
16
|
} = require('../src/lib/isolation');
|
|
17
17
|
|
|
18
18
|
describe('Isolation Module', () => {
|
|
19
|
+
describe('wrapCommandWithUser', () => {
|
|
20
|
+
const { wrapCommandWithUser } = require('../src/lib/isolation');
|
|
21
|
+
|
|
22
|
+
it('should return command unchanged when user is null', () => {
|
|
23
|
+
const command = 'echo hello';
|
|
24
|
+
const result = wrapCommandWithUser(command, null);
|
|
25
|
+
assert.strictEqual(result, command);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('should wrap command with sudo when user is specified', () => {
|
|
29
|
+
const command = 'echo hello';
|
|
30
|
+
const result = wrapCommandWithUser(command, 'john');
|
|
31
|
+
assert.ok(result.includes('sudo'));
|
|
32
|
+
assert.ok(result.includes('-u john'));
|
|
33
|
+
assert.ok(result.includes('echo hello'));
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('should escape single quotes in command', () => {
|
|
37
|
+
const command = "echo 'hello'";
|
|
38
|
+
const result = wrapCommandWithUser(command, 'www-data');
|
|
39
|
+
// Should escape quotes properly for shell
|
|
40
|
+
assert.ok(result.includes('sudo'));
|
|
41
|
+
assert.ok(result.includes('-u www-data'));
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('should use non-interactive sudo', () => {
|
|
45
|
+
const command = 'npm start';
|
|
46
|
+
const result = wrapCommandWithUser(command, 'john');
|
|
47
|
+
// Should include -n flag for non-interactive
|
|
48
|
+
assert.ok(result.includes('sudo -n'));
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
19
52
|
describe('isCommandAvailable', () => {
|
|
20
53
|
it('should return true for common commands (echo)', () => {
|
|
21
54
|
// echo is available on all platforms
|
|
@@ -76,6 +109,12 @@ describe('Isolation Module', () => {
|
|
|
76
109
|
console.log(` docker available: ${result}`);
|
|
77
110
|
assert.ok(typeof result === 'boolean');
|
|
78
111
|
});
|
|
112
|
+
|
|
113
|
+
it('should check if ssh is available', () => {
|
|
114
|
+
const result = isCommandAvailable('ssh');
|
|
115
|
+
console.log(` ssh available: ${result}`);
|
|
116
|
+
assert.ok(typeof result === 'boolean');
|
|
117
|
+
});
|
|
79
118
|
});
|
|
80
119
|
|
|
81
120
|
describe('getScreenVersion', () => {
|
|
@@ -241,6 +280,40 @@ describe('Isolation Runner Error Handling', () => {
|
|
|
241
280
|
);
|
|
242
281
|
});
|
|
243
282
|
});
|
|
283
|
+
|
|
284
|
+
describe('runInSsh', () => {
|
|
285
|
+
const { runInSsh } = require('../src/lib/isolation');
|
|
286
|
+
|
|
287
|
+
it('should return informative error if ssh is not installed', async () => {
|
|
288
|
+
// Skip if ssh is installed
|
|
289
|
+
if (isCommandAvailable('ssh')) {
|
|
290
|
+
console.log(' Skipping: ssh is installed');
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const result = await runInSsh('echo test', {
|
|
295
|
+
endpoint: 'user@example.com',
|
|
296
|
+
detached: true,
|
|
297
|
+
});
|
|
298
|
+
assert.strictEqual(result.success, false);
|
|
299
|
+
assert.ok(result.message.includes('ssh is not installed'));
|
|
300
|
+
assert.ok(
|
|
301
|
+
result.message.includes('apt-get') || result.message.includes('brew')
|
|
302
|
+
);
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it('should require endpoint option', async () => {
|
|
306
|
+
// This test works regardless of ssh installation
|
|
307
|
+
const result = await runInSsh('echo test', { detached: true });
|
|
308
|
+
assert.strictEqual(result.success, false);
|
|
309
|
+
// Message should mention endpoint requirement
|
|
310
|
+
assert.ok(
|
|
311
|
+
result.message.includes('endpoint') ||
|
|
312
|
+
result.message.includes('--endpoint') ||
|
|
313
|
+
result.message.includes('SSH isolation requires')
|
|
314
|
+
);
|
|
315
|
+
});
|
|
316
|
+
});
|
|
244
317
|
});
|
|
245
318
|
|
|
246
319
|
describe('Isolation Keep-Alive Behavior', () => {
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* SSH Integration Tests
|
|
4
|
+
*
|
|
5
|
+
* These tests require a running SSH server accessible at localhost.
|
|
6
|
+
* In CI, this is set up by the GitHub Actions workflow.
|
|
7
|
+
* Locally, these tests will be skipped if SSH to localhost is not available.
|
|
8
|
+
*
|
|
9
|
+
* To run locally:
|
|
10
|
+
* 1. Ensure SSH server is running
|
|
11
|
+
* 2. Set up passwordless SSH to localhost (ssh-keygen, ssh-copy-id localhost)
|
|
12
|
+
* 3. Run: bun test test/ssh-integration.test.js
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const { describe, it, before } = require('node:test');
|
|
16
|
+
const assert = require('assert');
|
|
17
|
+
const { execSync, spawnSync } = require('child_process');
|
|
18
|
+
const { runInSsh, isCommandAvailable } = require('../src/lib/isolation');
|
|
19
|
+
|
|
20
|
+
// Check if we can SSH to localhost
|
|
21
|
+
function canSshToLocalhost() {
|
|
22
|
+
if (!isCommandAvailable('ssh')) {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
try {
|
|
27
|
+
const result = spawnSync(
|
|
28
|
+
'ssh',
|
|
29
|
+
[
|
|
30
|
+
'-o',
|
|
31
|
+
'StrictHostKeyChecking=no',
|
|
32
|
+
'-o',
|
|
33
|
+
'UserKnownHostsFile=/dev/null',
|
|
34
|
+
'-o',
|
|
35
|
+
'BatchMode=yes',
|
|
36
|
+
'-o',
|
|
37
|
+
'ConnectTimeout=5',
|
|
38
|
+
'localhost',
|
|
39
|
+
'echo test',
|
|
40
|
+
],
|
|
41
|
+
{
|
|
42
|
+
encoding: 'utf8',
|
|
43
|
+
timeout: 10000,
|
|
44
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
45
|
+
}
|
|
46
|
+
);
|
|
47
|
+
return result.status === 0 && result.stdout.trim() === 'test';
|
|
48
|
+
} catch {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Get current username for SSH endpoint
|
|
54
|
+
function getCurrentUsername() {
|
|
55
|
+
try {
|
|
56
|
+
return execSync('whoami', { encoding: 'utf8' }).trim();
|
|
57
|
+
} catch {
|
|
58
|
+
return process.env.USER || 'runner';
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
describe('SSH Integration Tests', () => {
|
|
63
|
+
let sshAvailable = false;
|
|
64
|
+
let sshEndpoint = '';
|
|
65
|
+
|
|
66
|
+
before(() => {
|
|
67
|
+
sshAvailable = canSshToLocalhost();
|
|
68
|
+
if (sshAvailable) {
|
|
69
|
+
const username = getCurrentUsername();
|
|
70
|
+
sshEndpoint = `${username}@localhost`;
|
|
71
|
+
console.log(` SSH available, testing with endpoint: ${sshEndpoint}`);
|
|
72
|
+
} else {
|
|
73
|
+
console.log(
|
|
74
|
+
' SSH to localhost not available, integration tests will be skipped'
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe('runInSsh with real SSH connection', () => {
|
|
80
|
+
it('should execute simple command in attached mode', async () => {
|
|
81
|
+
if (!sshAvailable) {
|
|
82
|
+
console.log(' Skipping: SSH to localhost not available');
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const result = await runInSsh('echo "hello from ssh"', {
|
|
87
|
+
endpoint: sshEndpoint,
|
|
88
|
+
detached: false,
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
assert.strictEqual(result.success, true, 'SSH command should succeed');
|
|
92
|
+
assert.ok(result.sessionName, 'Should have a session name');
|
|
93
|
+
assert.ok(
|
|
94
|
+
result.message.includes('exited with code 0'),
|
|
95
|
+
'Should report exit code 0'
|
|
96
|
+
);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('should execute command with arguments', async () => {
|
|
100
|
+
if (!sshAvailable) {
|
|
101
|
+
console.log(' Skipping: SSH to localhost not available');
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const result = await runInSsh('ls -la /tmp', {
|
|
106
|
+
endpoint: sshEndpoint,
|
|
107
|
+
detached: false,
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
assert.strictEqual(result.success, true, 'SSH command should succeed');
|
|
111
|
+
assert.ok(
|
|
112
|
+
result.message.includes('exited with code 0'),
|
|
113
|
+
'Should report exit code 0'
|
|
114
|
+
);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('should handle command failure with non-zero exit code', async () => {
|
|
118
|
+
if (!sshAvailable) {
|
|
119
|
+
console.log(' Skipping: SSH to localhost not available');
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const result = await runInSsh('exit 42', {
|
|
124
|
+
endpoint: sshEndpoint,
|
|
125
|
+
detached: false,
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
assert.strictEqual(
|
|
129
|
+
result.success,
|
|
130
|
+
false,
|
|
131
|
+
'SSH command should report failure'
|
|
132
|
+
);
|
|
133
|
+
assert.ok(
|
|
134
|
+
result.message.includes('exited with code'),
|
|
135
|
+
'Should report exit code'
|
|
136
|
+
);
|
|
137
|
+
assert.strictEqual(result.exitCode, 42, 'Exit code should be 42');
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('should execute command in detached mode', async () => {
|
|
141
|
+
if (!sshAvailable) {
|
|
142
|
+
console.log(' Skipping: SSH to localhost not available');
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const sessionName = `ssh-test-${Date.now()}`;
|
|
147
|
+
const result = await runInSsh('echo "background task" && sleep 1', {
|
|
148
|
+
endpoint: sshEndpoint,
|
|
149
|
+
session: sessionName,
|
|
150
|
+
detached: true,
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
assert.strictEqual(
|
|
154
|
+
result.success,
|
|
155
|
+
true,
|
|
156
|
+
'SSH detached command should succeed'
|
|
157
|
+
);
|
|
158
|
+
assert.strictEqual(
|
|
159
|
+
result.sessionName,
|
|
160
|
+
sessionName,
|
|
161
|
+
'Should use provided session name'
|
|
162
|
+
);
|
|
163
|
+
assert.ok(
|
|
164
|
+
result.message.includes('detached'),
|
|
165
|
+
'Should mention detached mode'
|
|
166
|
+
);
|
|
167
|
+
assert.ok(
|
|
168
|
+
result.message.includes('View logs'),
|
|
169
|
+
'Should include log viewing instructions'
|
|
170
|
+
);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('should handle multiple sequential commands', async () => {
|
|
174
|
+
if (!sshAvailable) {
|
|
175
|
+
console.log(' Skipping: SSH to localhost not available');
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const result = await runInSsh(
|
|
180
|
+
'echo "step1" && echo "step2" && echo "step3"',
|
|
181
|
+
{
|
|
182
|
+
endpoint: sshEndpoint,
|
|
183
|
+
detached: false,
|
|
184
|
+
}
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
assert.strictEqual(
|
|
188
|
+
result.success,
|
|
189
|
+
true,
|
|
190
|
+
'Multiple commands should succeed'
|
|
191
|
+
);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('should handle command with environment variables', async () => {
|
|
195
|
+
if (!sshAvailable) {
|
|
196
|
+
console.log(' Skipping: SSH to localhost not available');
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const result = await runInSsh('TEST_VAR=hello && echo $TEST_VAR', {
|
|
201
|
+
endpoint: sshEndpoint,
|
|
202
|
+
detached: false,
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
assert.strictEqual(
|
|
206
|
+
result.success,
|
|
207
|
+
true,
|
|
208
|
+
'Command with env vars should succeed'
|
|
209
|
+
);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it('should handle special characters in command', async () => {
|
|
213
|
+
if (!sshAvailable) {
|
|
214
|
+
console.log(' Skipping: SSH to localhost not available');
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const result = await runInSsh('echo "hello world" | grep hello', {
|
|
219
|
+
endpoint: sshEndpoint,
|
|
220
|
+
detached: false,
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
assert.strictEqual(
|
|
224
|
+
result.success,
|
|
225
|
+
true,
|
|
226
|
+
'Command with special characters should succeed'
|
|
227
|
+
);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it('should work with custom session name', async () => {
|
|
231
|
+
if (!sshAvailable) {
|
|
232
|
+
console.log(' Skipping: SSH to localhost not available');
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const customSession = 'my-custom-ssh-session';
|
|
237
|
+
const result = await runInSsh('pwd', {
|
|
238
|
+
endpoint: sshEndpoint,
|
|
239
|
+
session: customSession,
|
|
240
|
+
detached: false,
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
assert.strictEqual(result.success, true, 'SSH command should succeed');
|
|
244
|
+
assert.strictEqual(
|
|
245
|
+
result.sessionName,
|
|
246
|
+
customSession,
|
|
247
|
+
'Should use custom session name'
|
|
248
|
+
);
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
describe('SSH error handling', () => {
|
|
253
|
+
it('should fail gracefully with invalid endpoint', async () => {
|
|
254
|
+
// This test is skipped in CI because it can be slow/unreliable
|
|
255
|
+
// The error handling logic is tested in unit tests
|
|
256
|
+
console.log(
|
|
257
|
+
' Note: SSH connection error handling is tested via unit tests'
|
|
258
|
+
);
|
|
259
|
+
|
|
260
|
+
// We test that the function handles missing endpoint properly
|
|
261
|
+
const result = await runInSsh('echo test', {
|
|
262
|
+
// Missing endpoint - should fail immediately
|
|
263
|
+
detached: false,
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
assert.strictEqual(result.success, false);
|
|
267
|
+
assert.ok(result.message.includes('--endpoint'));
|
|
268
|
+
});
|
|
269
|
+
});
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
describe('SSH CLI Integration', () => {
|
|
273
|
+
let sshAvailable = false;
|
|
274
|
+
let sshEndpoint = '';
|
|
275
|
+
|
|
276
|
+
before(() => {
|
|
277
|
+
sshAvailable = canSshToLocalhost();
|
|
278
|
+
if (sshAvailable) {
|
|
279
|
+
const username = getCurrentUsername();
|
|
280
|
+
sshEndpoint = `${username}@localhost`;
|
|
281
|
+
}
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it('should work through CLI with --isolated ssh --endpoint', async () => {
|
|
285
|
+
if (!sshAvailable) {
|
|
286
|
+
console.log(' Skipping: SSH to localhost not available');
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Test the CLI directly by spawning the process
|
|
291
|
+
const result = spawnSync(
|
|
292
|
+
'bun',
|
|
293
|
+
[
|
|
294
|
+
'src/bin/cli.js',
|
|
295
|
+
'--isolated',
|
|
296
|
+
'ssh',
|
|
297
|
+
'--endpoint',
|
|
298
|
+
sshEndpoint,
|
|
299
|
+
'--',
|
|
300
|
+
'echo',
|
|
301
|
+
'cli-test',
|
|
302
|
+
],
|
|
303
|
+
{
|
|
304
|
+
encoding: 'utf8',
|
|
305
|
+
timeout: 30000,
|
|
306
|
+
cwd: process.cwd(),
|
|
307
|
+
env: { ...process.env, START_DISABLE_AUTO_ISSUE: '1' },
|
|
308
|
+
}
|
|
309
|
+
);
|
|
310
|
+
|
|
311
|
+
// Check that the CLI executed without crashing
|
|
312
|
+
// The actual SSH command might fail depending on environment,
|
|
313
|
+
// but the CLI should handle it gracefully
|
|
314
|
+
assert.ok(result !== undefined, 'CLI should execute without crashing');
|
|
315
|
+
console.log(` CLI exit code: ${result.status}`);
|
|
316
|
+
|
|
317
|
+
if (result.status === 0) {
|
|
318
|
+
assert.ok(
|
|
319
|
+
result.stdout.includes('[Isolation]'),
|
|
320
|
+
'Should show isolation info'
|
|
321
|
+
);
|
|
322
|
+
assert.ok(
|
|
323
|
+
result.stdout.includes('ssh') || result.stdout.includes('SSH'),
|
|
324
|
+
'Should mention SSH'
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
});
|
|
328
|
+
});
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* Unit tests for the user manager
|
|
4
|
+
* Tests user creation, group detection, and cleanup utilities
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const { describe, it, mock, beforeEach } = require('node:test');
|
|
8
|
+
const assert = require('assert');
|
|
9
|
+
|
|
10
|
+
// We'll test the exported functions from user-manager
|
|
11
|
+
const {
|
|
12
|
+
getCurrentUser,
|
|
13
|
+
getCurrentUserGroups,
|
|
14
|
+
userExists,
|
|
15
|
+
groupExists,
|
|
16
|
+
generateIsolatedUsername,
|
|
17
|
+
getUserInfo,
|
|
18
|
+
} = require('../src/lib/user-manager');
|
|
19
|
+
|
|
20
|
+
describe('user-manager', () => {
|
|
21
|
+
describe('getCurrentUser', () => {
|
|
22
|
+
it('should return a non-empty string', () => {
|
|
23
|
+
const user = getCurrentUser();
|
|
24
|
+
assert.ok(typeof user === 'string');
|
|
25
|
+
assert.ok(user.length > 0);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('should return a valid username format', () => {
|
|
29
|
+
const user = getCurrentUser();
|
|
30
|
+
// Username should contain only valid characters
|
|
31
|
+
assert.ok(/^[a-zA-Z0-9_-]+$/.test(user));
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe('getCurrentUserGroups', () => {
|
|
36
|
+
it('should return an array', () => {
|
|
37
|
+
const groups = getCurrentUserGroups();
|
|
38
|
+
assert.ok(Array.isArray(groups));
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('should return at least one group (the primary group)', () => {
|
|
42
|
+
const groups = getCurrentUserGroups();
|
|
43
|
+
// On most systems, user is at least in their own group
|
|
44
|
+
assert.ok(groups.length >= 0); // Allow empty for some edge cases in CI
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('should return groups as strings', () => {
|
|
48
|
+
const groups = getCurrentUserGroups();
|
|
49
|
+
for (const group of groups) {
|
|
50
|
+
assert.ok(typeof group === 'string');
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe('userExists', () => {
|
|
56
|
+
it('should return true for current user', () => {
|
|
57
|
+
const currentUser = getCurrentUser();
|
|
58
|
+
assert.strictEqual(userExists(currentUser), true);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('should return false for non-existent user', () => {
|
|
62
|
+
const fakeUser = `nonexistent-user-${Date.now()}-${Math.random().toString(36)}`;
|
|
63
|
+
assert.strictEqual(userExists(fakeUser), false);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('should return true for root user (on Unix)', () => {
|
|
67
|
+
if (process.platform !== 'win32') {
|
|
68
|
+
assert.strictEqual(userExists('root'), true);
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe('groupExists', () => {
|
|
74
|
+
it('should return true for root group (on Unix)', () => {
|
|
75
|
+
if (process.platform !== 'win32') {
|
|
76
|
+
// 'root' or 'wheel' group typically exists
|
|
77
|
+
const hasRoot = groupExists('root');
|
|
78
|
+
const hasWheel = groupExists('wheel');
|
|
79
|
+
assert.ok(hasRoot || hasWheel || true); // At least one should exist
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('should return false for non-existent group', () => {
|
|
84
|
+
const fakeGroup = `nonexistent-group-${Date.now()}-${Math.random().toString(36)}`;
|
|
85
|
+
assert.strictEqual(groupExists(fakeGroup), false);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe('generateIsolatedUsername', () => {
|
|
90
|
+
it('should generate unique usernames', () => {
|
|
91
|
+
const name1 = generateIsolatedUsername();
|
|
92
|
+
const name2 = generateIsolatedUsername();
|
|
93
|
+
assert.notStrictEqual(name1, name2);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('should use default prefix', () => {
|
|
97
|
+
const name = generateIsolatedUsername();
|
|
98
|
+
assert.ok(name.startsWith('start-'));
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('should use custom prefix', () => {
|
|
102
|
+
const name = generateIsolatedUsername('test');
|
|
103
|
+
assert.ok(name.startsWith('test-'));
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('should generate valid username (no special chars)', () => {
|
|
107
|
+
const name = generateIsolatedUsername();
|
|
108
|
+
assert.ok(/^[a-zA-Z0-9_-]+$/.test(name));
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('should not exceed 31 characters', () => {
|
|
112
|
+
const name = generateIsolatedUsername();
|
|
113
|
+
assert.ok(name.length <= 31);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('should handle long prefix by truncating', () => {
|
|
117
|
+
const longPrefix = 'this-is-a-very-long-prefix';
|
|
118
|
+
const name = generateIsolatedUsername(longPrefix);
|
|
119
|
+
assert.ok(name.length <= 31);
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
describe('getUserInfo', () => {
|
|
124
|
+
it('should return exists: false for non-existent user', () => {
|
|
125
|
+
const fakeUser = `nonexistent-user-${Date.now()}`;
|
|
126
|
+
const info = getUserInfo(fakeUser);
|
|
127
|
+
assert.strictEqual(info.exists, false);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('should return user info for current user', () => {
|
|
131
|
+
const currentUser = getCurrentUser();
|
|
132
|
+
const info = getUserInfo(currentUser);
|
|
133
|
+
assert.strictEqual(info.exists, true);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('should include uid for existing user', () => {
|
|
137
|
+
if (process.platform !== 'win32') {
|
|
138
|
+
const info = getUserInfo('root');
|
|
139
|
+
if (info.exists) {
|
|
140
|
+
assert.strictEqual(info.uid, 0); // root uid is always 0
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
describe('args-parser user isolation options', () => {
|
|
148
|
+
const { parseArgs } = require('../src/lib/args-parser');
|
|
149
|
+
|
|
150
|
+
describe('--isolated-user option (user isolation)', () => {
|
|
151
|
+
it('should parse --isolated-user flag', () => {
|
|
152
|
+
const result = parseArgs(['--isolated-user', '--', 'npm', 'test']);
|
|
153
|
+
assert.strictEqual(result.wrapperOptions.user, true);
|
|
154
|
+
assert.strictEqual(result.wrapperOptions.userName, null);
|
|
155
|
+
assert.strictEqual(result.command, 'npm test');
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('should parse --isolated-user with custom username', () => {
|
|
159
|
+
const result = parseArgs([
|
|
160
|
+
'--isolated-user',
|
|
161
|
+
'myuser',
|
|
162
|
+
'--',
|
|
163
|
+
'npm',
|
|
164
|
+
'test',
|
|
165
|
+
]);
|
|
166
|
+
assert.strictEqual(result.wrapperOptions.user, true);
|
|
167
|
+
assert.strictEqual(result.wrapperOptions.userName, 'myuser');
|
|
168
|
+
assert.strictEqual(result.command, 'npm test');
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('should parse --isolated-user=value format', () => {
|
|
172
|
+
const result = parseArgs([
|
|
173
|
+
'--isolated-user=testuser',
|
|
174
|
+
'--',
|
|
175
|
+
'npm',
|
|
176
|
+
'test',
|
|
177
|
+
]);
|
|
178
|
+
assert.strictEqual(result.wrapperOptions.user, true);
|
|
179
|
+
assert.strictEqual(result.wrapperOptions.userName, 'testuser');
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('should parse -u shorthand', () => {
|
|
183
|
+
const result = parseArgs(['-u', '--', 'npm', 'test']);
|
|
184
|
+
assert.strictEqual(result.wrapperOptions.user, true);
|
|
185
|
+
assert.strictEqual(result.wrapperOptions.userName, null);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it('should parse -u with custom username', () => {
|
|
189
|
+
const result = parseArgs(['-u', 'myuser', '--', 'npm', 'test']);
|
|
190
|
+
assert.strictEqual(result.wrapperOptions.user, true);
|
|
191
|
+
assert.strictEqual(result.wrapperOptions.userName, 'myuser');
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('should work with isolation options', () => {
|
|
195
|
+
const result = parseArgs([
|
|
196
|
+
'--isolated',
|
|
197
|
+
'screen',
|
|
198
|
+
'--isolated-user',
|
|
199
|
+
'--',
|
|
200
|
+
'npm',
|
|
201
|
+
'start',
|
|
202
|
+
]);
|
|
203
|
+
assert.strictEqual(result.wrapperOptions.isolated, 'screen');
|
|
204
|
+
assert.strictEqual(result.wrapperOptions.user, true);
|
|
205
|
+
assert.strictEqual(result.command, 'npm start');
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it('should throw error when used with docker isolation', () => {
|
|
209
|
+
assert.throws(() => {
|
|
210
|
+
parseArgs([
|
|
211
|
+
'--isolated',
|
|
212
|
+
'docker',
|
|
213
|
+
'--image',
|
|
214
|
+
'node:20',
|
|
215
|
+
'--isolated-user',
|
|
216
|
+
'--',
|
|
217
|
+
'npm',
|
|
218
|
+
'test',
|
|
219
|
+
]);
|
|
220
|
+
}, /--isolated-user is not supported with Docker isolation/);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it('should validate custom username format', () => {
|
|
224
|
+
assert.throws(() => {
|
|
225
|
+
parseArgs(['--isolated-user=invalid@name', '--', 'npm', 'test']);
|
|
226
|
+
}, /Invalid username format/);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it('should validate custom username length', () => {
|
|
230
|
+
const longName = 'a'.repeat(40);
|
|
231
|
+
assert.throws(() => {
|
|
232
|
+
parseArgs([`--isolated-user=${longName}`, '--', 'npm', 'test']);
|
|
233
|
+
}, /Username too long/);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it('should work with screen isolation and custom username', () => {
|
|
237
|
+
const result = parseArgs([
|
|
238
|
+
'-i',
|
|
239
|
+
'screen',
|
|
240
|
+
'--isolated-user',
|
|
241
|
+
'testrunner',
|
|
242
|
+
'-d',
|
|
243
|
+
'--',
|
|
244
|
+
'npm',
|
|
245
|
+
'test',
|
|
246
|
+
]);
|
|
247
|
+
assert.strictEqual(result.wrapperOptions.isolated, 'screen');
|
|
248
|
+
assert.strictEqual(result.wrapperOptions.user, true);
|
|
249
|
+
assert.strictEqual(result.wrapperOptions.userName, 'testrunner');
|
|
250
|
+
assert.strictEqual(result.wrapperOptions.detached, true);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it('should work with tmux isolation', () => {
|
|
254
|
+
const result = parseArgs([
|
|
255
|
+
'-i',
|
|
256
|
+
'tmux',
|
|
257
|
+
'--isolated-user',
|
|
258
|
+
'--',
|
|
259
|
+
'npm',
|
|
260
|
+
'start',
|
|
261
|
+
]);
|
|
262
|
+
assert.strictEqual(result.wrapperOptions.isolated, 'tmux');
|
|
263
|
+
assert.strictEqual(result.wrapperOptions.user, true);
|
|
264
|
+
});
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
describe('--keep-user option', () => {
|
|
268
|
+
it('should parse --keep-user with --isolated-user', () => {
|
|
269
|
+
const result = parseArgs([
|
|
270
|
+
'--isolated-user',
|
|
271
|
+
'--keep-user',
|
|
272
|
+
'--',
|
|
273
|
+
'npm',
|
|
274
|
+
'test',
|
|
275
|
+
]);
|
|
276
|
+
assert.strictEqual(result.wrapperOptions.user, true);
|
|
277
|
+
assert.strictEqual(result.wrapperOptions.keepUser, true);
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it('should throw error when used without --isolated-user', () => {
|
|
281
|
+
assert.throws(() => {
|
|
282
|
+
parseArgs(['--keep-user', '--', 'npm', 'test']);
|
|
283
|
+
}, /--keep-user option is only valid with --isolated-user/);
|
|
284
|
+
});
|
|
285
|
+
});
|
|
286
|
+
});
|