takos-runtime-service 1.0.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.
Files changed (85) hide show
  1. package/package.json +29 -0
  2. package/src/__tests__/middleware/rate-limit.test.ts +33 -0
  3. package/src/__tests__/middleware/workspace-scope-extended.test.ts +163 -0
  4. package/src/__tests__/routes/actions-start-limits.test.ts +139 -0
  5. package/src/__tests__/routes/actions-step-warnings.test.ts +194 -0
  6. package/src/__tests__/routes/cli-proxy.test.ts +72 -0
  7. package/src/__tests__/routes/git-http.test.ts +218 -0
  8. package/src/__tests__/routes/git-lfs-policy.test.ts +112 -0
  9. package/src/__tests__/routes/sessions/store.test.ts +72 -0
  10. package/src/__tests__/routes/workspace-scope.test.ts +45 -0
  11. package/src/__tests__/runtime/action-registry.test.ts +208 -0
  12. package/src/__tests__/runtime/action-result-helpers.test.ts +129 -0
  13. package/src/__tests__/runtime/actions/executor.test.ts +131 -0
  14. package/src/__tests__/runtime/composite-expression.test.ts +294 -0
  15. package/src/__tests__/runtime/file-parsers.test.ts +129 -0
  16. package/src/__tests__/runtime/logging.test.ts +65 -0
  17. package/src/__tests__/runtime/paths.test.ts +236 -0
  18. package/src/__tests__/runtime/secrets.test.ts +247 -0
  19. package/src/__tests__/runtime/validation.test.ts +516 -0
  20. package/src/__tests__/setup.ts +126 -0
  21. package/src/__tests__/shared/errors.test.ts +117 -0
  22. package/src/__tests__/storage/r2.test.ts +106 -0
  23. package/src/__tests__/utils/audit-log.test.ts +163 -0
  24. package/src/__tests__/utils/error-message.test.ts +38 -0
  25. package/src/__tests__/utils/sandbox-env.test.ts +74 -0
  26. package/src/app.ts +245 -0
  27. package/src/index.ts +1 -0
  28. package/src/middleware/rate-limit.ts +91 -0
  29. package/src/middleware/space-scope.ts +95 -0
  30. package/src/routes/actions/action-types.ts +20 -0
  31. package/src/routes/actions/execution.ts +229 -0
  32. package/src/routes/actions/index.ts +17 -0
  33. package/src/routes/actions/job-lifecycle.ts +242 -0
  34. package/src/routes/actions/job-queries.ts +52 -0
  35. package/src/routes/cli/proxy.ts +105 -0
  36. package/src/routes/git/http.ts +565 -0
  37. package/src/routes/git/init.ts +88 -0
  38. package/src/routes/repos/branches.ts +160 -0
  39. package/src/routes/repos/content.ts +209 -0
  40. package/src/routes/repos/read.ts +130 -0
  41. package/src/routes/repos/repo-validation.ts +136 -0
  42. package/src/routes/repos/write.ts +274 -0
  43. package/src/routes/runtime/exec.ts +147 -0
  44. package/src/routes/runtime/tools.ts +113 -0
  45. package/src/routes/sessions/execution.ts +263 -0
  46. package/src/routes/sessions/files.ts +326 -0
  47. package/src/routes/sessions/session-routes.ts +241 -0
  48. package/src/routes/sessions/session-utils.ts +88 -0
  49. package/src/routes/sessions/snapshot.ts +208 -0
  50. package/src/routes/sessions/storage.ts +329 -0
  51. package/src/runtime/actions/action-registry.ts +450 -0
  52. package/src/runtime/actions/action-result-converter.ts +31 -0
  53. package/src/runtime/actions/builtin/artifacts.ts +292 -0
  54. package/src/runtime/actions/builtin/cache-operations.ts +358 -0
  55. package/src/runtime/actions/builtin/checkout.ts +58 -0
  56. package/src/runtime/actions/builtin/index.ts +5 -0
  57. package/src/runtime/actions/builtin/setup-node.ts +86 -0
  58. package/src/runtime/actions/builtin/tar-parser.ts +175 -0
  59. package/src/runtime/actions/composite-executor.ts +192 -0
  60. package/src/runtime/actions/composite-expression.ts +190 -0
  61. package/src/runtime/actions/executor.ts +578 -0
  62. package/src/runtime/actions/file-parsers.ts +51 -0
  63. package/src/runtime/actions/job-manager.ts +213 -0
  64. package/src/runtime/actions/process-spawner.ts +275 -0
  65. package/src/runtime/actions/secrets.ts +162 -0
  66. package/src/runtime/command.ts +120 -0
  67. package/src/runtime/exec-runner.ts +309 -0
  68. package/src/runtime/git-http-backend.ts +145 -0
  69. package/src/runtime/git.ts +98 -0
  70. package/src/runtime/heartbeat.ts +57 -0
  71. package/src/runtime/logging.ts +26 -0
  72. package/src/runtime/paths.ts +264 -0
  73. package/src/runtime/secure-fs.ts +82 -0
  74. package/src/runtime/tools/network.ts +161 -0
  75. package/src/runtime/tools/worker.ts +335 -0
  76. package/src/runtime/validation.ts +292 -0
  77. package/src/shared/config.ts +149 -0
  78. package/src/shared/errors.ts +65 -0
  79. package/src/shared/temp-id.ts +10 -0
  80. package/src/storage/r2.ts +287 -0
  81. package/src/types/hono.d.ts +23 -0
  82. package/src/utils/audit-log.ts +92 -0
  83. package/src/utils/process-kill.ts +18 -0
  84. package/src/utils/sandbox-env.ts +136 -0
  85. package/src/utils/temp-dir.ts +74 -0
@@ -0,0 +1,516 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+
3
+ vi.mock('../../shared/config.js', () => ({
4
+ ALLOWED_COMMANDS_SET: new Set([
5
+ 'npm', 'npx', 'node', 'pnpm', 'git', 'echo', 'ls', 'cat', 'curl', 'wget',
6
+ 'grep', 'find', 'sed', 'awk', 'tar', 'chmod',
7
+ ]),
8
+ COMMAND_BLOCKLIST_PATTERNS: [
9
+ /\brm\s+(-[a-zA-Z]*r[a-zA-Z]*\s+)?[/\\]\*?\s*$/i,
10
+ /\brm\s+(-[a-zA-Z]*r[a-zA-Z]*\s+)?[/\\]\s/i,
11
+ /\brm\s+(-[a-zA-Z]*r[a-zA-Z]*\s+)?~[/\s]*$/i,
12
+ /\breboot\b/i,
13
+ /\bshutdown\b/i,
14
+ /\bpoweroff\b/i,
15
+ /\bhalt\b/i,
16
+ /:\(\)\s*\{\s*:\s*\|\s*:\s*&\s*\}/,
17
+ /\bdd\b.*\bof=\/dev\//i,
18
+ /\bmkfs\b/i,
19
+ /\bchmod\s+(-[a-zA-Z]*\s+)?[0-7]*777\s+[/\\]/i,
20
+ /\bmount\b/i,
21
+ /\bumount\b/i,
22
+ /\b(curl|wget)\b.*\b169\.254\.169\.254\b/i,
23
+ /\b(curl|wget)\b.*\bmetadata\.google\.internal\b/i,
24
+ /\b(curl|wget)\b.*\b100\.100\.100\.200\b/i,
25
+ /\b(curl|wget)\b.*\bfd00::1\b/i,
26
+ ],
27
+ }));
28
+
29
+ import {
30
+ validateCommandLine,
31
+ isProbablyBinary,
32
+ isValidSessionId,
33
+ getWorkerResourceLimits,
34
+ validateGitRef,
35
+ validateGitPath,
36
+ validateGitAuthorName,
37
+ validateGitAuthorEmail,
38
+ validateGitName,
39
+ validateSpaceId,
40
+ validateNameParam,
41
+ validateCommand,
42
+ } from '../../runtime/validation.js';
43
+
44
+ // ---------------------------------------------------------------------------
45
+ // validateCommandLine
46
+ // ---------------------------------------------------------------------------
47
+
48
+ describe('validateCommandLine', () => {
49
+ it('accepts valid command', () => {
50
+ expect(() => validateCommandLine('echo hello')).not.toThrow();
51
+ });
52
+
53
+ it('rejects empty command', () => {
54
+ expect(() => validateCommandLine('')).toThrow('Invalid command');
55
+ expect(() => validateCommandLine(' ')).toThrow('Invalid command');
56
+ });
57
+
58
+ it('rejects null bytes in command', () => {
59
+ expect(() => validateCommandLine('echo\u0000hello')).toThrow('Invalid command');
60
+ });
61
+
62
+ it('rejects reboot command', () => {
63
+ expect(() => validateCommandLine('reboot')).toThrow('Dangerous command');
64
+ });
65
+
66
+ it('rejects shutdown command', () => {
67
+ expect(() => validateCommandLine('shutdown -h now')).toThrow('Dangerous command');
68
+ });
69
+
70
+ it('rejects fork bomb patterns', () => {
71
+ expect(() => validateCommandLine(':() { : | : & }')).toThrow('Dangerous command');
72
+ });
73
+
74
+ it('rejects dd to device', () => {
75
+ expect(() => validateCommandLine('dd if=/dev/zero of=/dev/sda')).toThrow('Dangerous command');
76
+ });
77
+ });
78
+
79
+ // ---------------------------------------------------------------------------
80
+ // isProbablyBinary
81
+ // ---------------------------------------------------------------------------
82
+
83
+ describe('isProbablyBinary', () => {
84
+ it('returns false for empty buffer', () => {
85
+ expect(isProbablyBinary(Buffer.alloc(0))).toBe(false);
86
+ });
87
+
88
+ it('returns true for null byte', () => {
89
+ expect(isProbablyBinary(Buffer.from([0x00, 0x41, 0x42]))).toBe(true);
90
+ });
91
+
92
+ it('returns false for regular text', () => {
93
+ expect(isProbablyBinary(Buffer.from('Hello, world! This is normal text.'))).toBe(false);
94
+ });
95
+
96
+ it('returns true for high ratio of suspicious bytes', () => {
97
+ // Create buffer with many control characters
98
+ const buf = Buffer.alloc(100);
99
+ for (let i = 0; i < 100; i++) {
100
+ buf[i] = i % 3 === 0 ? 0x01 : 0x41; // mix control chars and 'A'
101
+ }
102
+ expect(isProbablyBinary(buf)).toBe(true);
103
+ });
104
+
105
+ it('returns false for buffer with few suspicious bytes', () => {
106
+ const text = 'Hello world with tab\there\n';
107
+ expect(isProbablyBinary(Buffer.from(text))).toBe(false);
108
+ });
109
+ });
110
+
111
+ // ---------------------------------------------------------------------------
112
+ // isValidSessionId
113
+ // ---------------------------------------------------------------------------
114
+
115
+ describe('isValidSessionId', () => {
116
+ it('accepts valid alphanumeric session ID (16+ chars)', () => {
117
+ expect(isValidSessionId('abcdefghijklmnop')).toBe(true);
118
+ });
119
+
120
+ it('accepts session ID with hyphens and underscores', () => {
121
+ expect(isValidSessionId('abc-def_ghi-jklmnop')).toBe(true);
122
+ });
123
+
124
+ it('rejects too-short session ID', () => {
125
+ expect(isValidSessionId('abc')).toBe(false);
126
+ });
127
+
128
+ it('rejects session ID starting with hyphen', () => {
129
+ expect(isValidSessionId('-abcdefghijklmnop')).toBe(false);
130
+ });
131
+
132
+ it('rejects session ID ending with hyphen', () => {
133
+ expect(isValidSessionId('abcdefghijklmnop-')).toBe(false);
134
+ });
135
+
136
+ it('rejects session ID with consecutive separators', () => {
137
+ expect(isValidSessionId('abcdefg--hijklmnop')).toBe(false);
138
+ });
139
+
140
+ it('rejects non-string input', () => {
141
+ // @ts-expect-error testing runtime behavior
142
+ expect(isValidSessionId(123)).toBe(false);
143
+ });
144
+
145
+ it('rejects empty string', () => {
146
+ expect(isValidSessionId('')).toBe(false);
147
+ });
148
+ });
149
+
150
+ // ---------------------------------------------------------------------------
151
+ // getWorkerResourceLimits
152
+ // ---------------------------------------------------------------------------
153
+
154
+ describe('getWorkerResourceLimits', () => {
155
+ it('returns undefined when maxMemory is falsy', () => {
156
+ expect(getWorkerResourceLimits(0)).toBeUndefined();
157
+ expect(getWorkerResourceLimits(undefined)).toBeUndefined();
158
+ });
159
+
160
+ it('clamps to minimum of 16 MB', () => {
161
+ expect(getWorkerResourceLimits(4)).toEqual({ maxOldGenerationSizeMb: 16 });
162
+ });
163
+
164
+ it('clamps to maximum of 512 MB', () => {
165
+ expect(getWorkerResourceLimits(1024)).toEqual({ maxOldGenerationSizeMb: 512 });
166
+ });
167
+
168
+ it('floors the value', () => {
169
+ expect(getWorkerResourceLimits(256.7)).toEqual({ maxOldGenerationSizeMb: 256 });
170
+ });
171
+
172
+ it('accepts values in range', () => {
173
+ expect(getWorkerResourceLimits(128)).toEqual({ maxOldGenerationSizeMb: 128 });
174
+ });
175
+ });
176
+
177
+ // ---------------------------------------------------------------------------
178
+ // validateGitRef
179
+ // ---------------------------------------------------------------------------
180
+
181
+ describe('validateGitRef', () => {
182
+ it('accepts valid branch name', () => {
183
+ expect(() => validateGitRef('main')).not.toThrow();
184
+ expect(() => validateGitRef('feature/new-thing')).not.toThrow();
185
+ });
186
+
187
+ it('accepts commit hash', () => {
188
+ expect(() => validateGitRef('abc123def456')).not.toThrow();
189
+ });
190
+
191
+ it('accepts tags', () => {
192
+ expect(() => validateGitRef('v1.0.0')).not.toThrow();
193
+ });
194
+
195
+ it('rejects empty string', () => {
196
+ expect(() => validateGitRef('')).toThrow('Git ref is required');
197
+ });
198
+
199
+ it('rejects ref starting with dot', () => {
200
+ expect(() => validateGitRef('.hidden')).toThrow('Git ref format is invalid');
201
+ });
202
+
203
+ it('rejects ref ending with dot', () => {
204
+ expect(() => validateGitRef('branch.')).toThrow('Git ref format is invalid');
205
+ });
206
+
207
+ it('rejects ref with double dots', () => {
208
+ expect(() => validateGitRef('branch..name')).toThrow('Git ref format is invalid');
209
+ });
210
+
211
+ it('rejects ref starting with dash', () => {
212
+ expect(() => validateGitRef('-flag')).toThrow('Git ref must not start with a dash');
213
+ });
214
+
215
+ it('rejects ref ending with .lock', () => {
216
+ expect(() => validateGitRef('branch.lock')).toThrow('Git ref must not end with .lock');
217
+ });
218
+
219
+ it('rejects ref starting with colon', () => {
220
+ expect(() => validateGitRef(':ref')).toThrow('Git ref must not start with a colon');
221
+ });
222
+
223
+ it('rejects ref ending with slash', () => {
224
+ expect(() => validateGitRef('branch/')).toThrow('Git ref must not end with a slash');
225
+ });
226
+
227
+ it('rejects ref with whitespace', () => {
228
+ expect(() => validateGitRef('branch name')).toThrow('Git ref must not contain whitespace');
229
+ });
230
+
231
+ it('rejects ref with backslash', () => {
232
+ expect(() => validateGitRef('branch\\name')).toThrow('Git ref contains invalid characters');
233
+ });
234
+
235
+ it('rejects ref with control characters', () => {
236
+ expect(() => validateGitRef('branch\u0001name')).toThrow();
237
+ });
238
+
239
+ it('rejects ref with @{ sequence', () => {
240
+ expect(() => validateGitRef('branch@{0}')).toThrow('Git ref format is invalid');
241
+ });
242
+
243
+ it('rejects ref exceeding 256 characters', () => {
244
+ expect(() => validateGitRef('a'.repeat(257))).toThrow('Git ref too long');
245
+ });
246
+ });
247
+
248
+ // ---------------------------------------------------------------------------
249
+ // validateGitPath
250
+ // ---------------------------------------------------------------------------
251
+
252
+ describe('validateGitPath', () => {
253
+ it('accepts valid file path', () => {
254
+ expect(() => validateGitPath('src/index.ts')).not.toThrow();
255
+ });
256
+
257
+ it('accepts filename with dots', () => {
258
+ expect(() => validateGitPath('package.json')).not.toThrow();
259
+ });
260
+
261
+ it('accepts empty path (no file)', () => {
262
+ expect(() => validateGitPath('')).not.toThrow();
263
+ });
264
+
265
+ it('rejects path traversal with ..', () => {
266
+ expect(() => validateGitPath('../etc/passwd')).toThrow('Path traversal not allowed');
267
+ });
268
+
269
+ it('rejects absolute path', () => {
270
+ expect(() => validateGitPath('/etc/passwd')).toThrow('Path traversal not allowed');
271
+ });
272
+
273
+ it('rejects Windows absolute path', () => {
274
+ expect(() => validateGitPath('C:\\Windows\\System32')).toThrow('Path traversal not allowed');
275
+ });
276
+
277
+ it('rejects control characters', () => {
278
+ expect(() => validateGitPath('file\u0000name')).toThrow('contains invalid characters');
279
+ });
280
+
281
+ it('rejects path exceeding 4096 characters', () => {
282
+ expect(() => validateGitPath('a'.repeat(4097))).toThrow('Git path too long');
283
+ });
284
+
285
+ it('rejects non-string input', () => {
286
+ // @ts-expect-error testing runtime behavior
287
+ expect(() => validateGitPath(123)).toThrow('Git path must be a string');
288
+ });
289
+ });
290
+
291
+ // ---------------------------------------------------------------------------
292
+ // validateGitAuthorName
293
+ // ---------------------------------------------------------------------------
294
+
295
+ describe('validateGitAuthorName', () => {
296
+ it('accepts valid name', () => {
297
+ expect(() => validateGitAuthorName('John Doe')).not.toThrow();
298
+ });
299
+
300
+ it('rejects empty name', () => {
301
+ expect(() => validateGitAuthorName('')).toThrow('Author name is required');
302
+ });
303
+
304
+ it('rejects name with angle brackets', () => {
305
+ expect(() => validateGitAuthorName('John <script>')).toThrow('disallowed characters');
306
+ });
307
+
308
+ it('rejects name with shell metacharacters', () => {
309
+ expect(() => validateGitAuthorName('John; rm -rf /')).toThrow('disallowed characters');
310
+ });
311
+
312
+ it('rejects name exceeding 256 characters', () => {
313
+ expect(() => validateGitAuthorName('a'.repeat(257))).toThrow('Author name too long');
314
+ });
315
+
316
+ it('rejects name with control characters', () => {
317
+ expect(() => validateGitAuthorName('name\u0000test')).toThrow('contains invalid characters');
318
+ });
319
+ });
320
+
321
+ // ---------------------------------------------------------------------------
322
+ // validateGitAuthorEmail
323
+ // ---------------------------------------------------------------------------
324
+
325
+ describe('validateGitAuthorEmail', () => {
326
+ it('accepts valid email', () => {
327
+ expect(() => validateGitAuthorEmail('user@example.com')).not.toThrow();
328
+ });
329
+
330
+ it('rejects empty email', () => {
331
+ expect(() => validateGitAuthorEmail('')).toThrow('Author email is required');
332
+ });
333
+
334
+ it('rejects invalid email format', () => {
335
+ expect(() => validateGitAuthorEmail('not-an-email')).toThrow('Author email format is invalid');
336
+ });
337
+
338
+ it('rejects email exceeding 256 characters', () => {
339
+ expect(() => validateGitAuthorEmail('a'.repeat(260) + '@b.com')).toThrow('Author email too long');
340
+ });
341
+
342
+ it('rejects email with control characters', () => {
343
+ expect(() => validateGitAuthorEmail('user\u0000@test.com')).toThrow('contains invalid characters');
344
+ });
345
+ });
346
+
347
+ // ---------------------------------------------------------------------------
348
+ // validateGitName
349
+ // ---------------------------------------------------------------------------
350
+
351
+ describe('validateGitName', () => {
352
+ it('accepts valid name', () => {
353
+ expect(validateGitName('my-repo')).toBe('my-repo');
354
+ });
355
+
356
+ it('accepts single character', () => {
357
+ expect(validateGitName('a')).toBe('a');
358
+ });
359
+
360
+ it('accepts name with underscore', () => {
361
+ expect(validateGitName('my_repo')).toBe('my_repo');
362
+ });
363
+
364
+ it('rejects empty string', () => {
365
+ expect(validateGitName('')).toBeNull();
366
+ });
367
+
368
+ it('rejects name exceeding 128 characters', () => {
369
+ expect(validateGitName('a'.repeat(129))).toBeNull();
370
+ });
371
+
372
+ it('rejects name with path traversal', () => {
373
+ expect(validateGitName('../secret')).toBeNull();
374
+ });
375
+
376
+ it('rejects name with forward slash', () => {
377
+ expect(validateGitName('path/to/repo')).toBeNull();
378
+ });
379
+
380
+ it('rejects name with backslash', () => {
381
+ expect(validateGitName('path\\to')).toBeNull();
382
+ });
383
+
384
+ it('rejects name with control characters', () => {
385
+ expect(validateGitName('name\u0000evil')).toBeNull();
386
+ });
387
+
388
+ it('rejects URL-encoded traversal', () => {
389
+ expect(validateGitName('%2e%2e')).toBeNull();
390
+ });
391
+
392
+ it('rejects consecutive underscores', () => {
393
+ expect(validateGitName('my__repo')).toBeNull();
394
+ });
395
+
396
+ it('rejects consecutive hyphens', () => {
397
+ expect(validateGitName('my--repo')).toBeNull();
398
+ });
399
+
400
+ it('rejects name starting with underscore', () => {
401
+ expect(validateGitName('_repo')).toBeNull();
402
+ });
403
+
404
+ it('rejects name starting with hyphen', () => {
405
+ expect(validateGitName('-repo')).toBeNull();
406
+ });
407
+
408
+ it('rejects non-string input', () => {
409
+ // @ts-expect-error testing runtime behavior
410
+ expect(validateGitName(null)).toBeNull();
411
+ });
412
+ });
413
+
414
+ // ---------------------------------------------------------------------------
415
+ // validateSpaceId
416
+ // ---------------------------------------------------------------------------
417
+
418
+ describe('validateSpaceId', () => {
419
+ it('accepts valid space ID', () => {
420
+ expect(validateSpaceId('ws123')).toBe('ws123');
421
+ });
422
+
423
+ it('accepts ID with hyphens', () => {
424
+ expect(validateSpaceId('my-workspace')).toBe('my-workspace');
425
+ });
426
+
427
+ it('throws on empty string', () => {
428
+ expect(() => validateSpaceId('')).toThrow('space_id is required');
429
+ });
430
+
431
+ it('throws on invalid format', () => {
432
+ expect(() => validateSpaceId('!invalid')).toThrow('Invalid space_id format');
433
+ });
434
+
435
+ it('throws on non-string', () => {
436
+ // @ts-expect-error testing runtime behavior
437
+ expect(() => validateSpaceId(null)).toThrow('space_id is required');
438
+ });
439
+ });
440
+
441
+ // ---------------------------------------------------------------------------
442
+ // validateNameParam
443
+ // ---------------------------------------------------------------------------
444
+
445
+ describe('validateNameParam', () => {
446
+ it('returns null for valid name', () => {
447
+ expect(validateNameParam('my-repo', 'repo')).toBeNull();
448
+ });
449
+
450
+ it('returns error for missing name', () => {
451
+ expect(validateNameParam(undefined, 'repo')).toBe('repo is required');
452
+ expect(validateNameParam('', 'repo')).toBe('repo is required');
453
+ });
454
+
455
+ it('returns error for invalid format', () => {
456
+ expect(validateNameParam('!invalid', 'repo')).toBe('Invalid repo format');
457
+ });
458
+ });
459
+
460
+ // ---------------------------------------------------------------------------
461
+ // validateCommand
462
+ // ---------------------------------------------------------------------------
463
+
464
+ describe('validateCommand', () => {
465
+ it('returns null for valid allowed command', () => {
466
+ expect(validateCommand('npm install')).toBeNull();
467
+ });
468
+
469
+ it('returns null for allowed command with arguments', () => {
470
+ expect(validateCommand('git commit -m "message"')).toBeNull();
471
+ });
472
+
473
+ it('returns error for empty command', () => {
474
+ expect(validateCommand('')).toBe('Command is empty or invalid');
475
+ });
476
+
477
+ it('returns error for whitespace-only command', () => {
478
+ expect(validateCommand(' ')).toBe('Command is empty or invalid');
479
+ });
480
+
481
+ it('returns error for too-long command', () => {
482
+ expect(validateCommand('a'.repeat(100001))).toBe('Command is too long');
483
+ });
484
+
485
+ it('returns error for shell metacharacters', () => {
486
+ const result = validateCommand('echo hello | grep world');
487
+ expect(result).toContain('shell metacharacters');
488
+ });
489
+
490
+ it('returns error for disallowed command', () => {
491
+ const result = validateCommand('python3 script.py');
492
+ expect(result).toContain('Command not allowed');
493
+ });
494
+
495
+ it('accepts relative path commands', () => {
496
+ expect(validateCommand('./my-script')).toBeNull();
497
+ });
498
+
499
+ it('returns error for control characters', () => {
500
+ expect(validateCommand('echo\u0001hello')).toBe('Command contains invalid control characters');
501
+ });
502
+
503
+ it('allows comments in multiline commands', () => {
504
+ expect(validateCommand('# This is a comment\nnpm install')).toBeNull();
505
+ });
506
+
507
+ it('blocks SSRF attempts via curl', () => {
508
+ const result = validateCommand('curl http://169.254.169.254/latest/meta-data/');
509
+ expect(result).toContain('dangerous patterns');
510
+ });
511
+
512
+ it('blocks SSRF attempts via wget', () => {
513
+ const result = validateCommand('wget http://metadata.google.internal/computeMetadata/v1/');
514
+ expect(result).toContain('dangerous patterns');
515
+ });
516
+ });
@@ -0,0 +1,126 @@
1
+ import { afterAll, afterEach, beforeAll, vi } from 'vitest';
2
+ import { Hono } from 'hono';
3
+ import { generateKeyPairSync } from 'node:crypto';
4
+
5
+ const testServiceJwtKeys = generateKeyPairSync('rsa', {
6
+ modulusLength: 2048,
7
+ publicKeyEncoding: { type: 'spki', format: 'pem' },
8
+ privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
9
+ });
10
+
11
+ process.env.JWT_PUBLIC_KEY ||= testServiceJwtKeys.publicKey;
12
+
13
+ // Route tests focus on route behavior. Allow missing service-token scope to pass.
14
+ vi.mock('../middleware/space-scope.js', async () => {
15
+ const actual = await vi.importActual<any>('../middleware/space-scope.js');
16
+ const original = actual.enforceSpaceScopeMiddleware as (getIds: any) => any;
17
+
18
+ return {
19
+ ...actual,
20
+ enforceSpaceScopeMiddleware: (getIds: any) => {
21
+ const middleware = original(getIds);
22
+ return async (c: any, next: any) => {
23
+ const token = c.get?.('serviceToken') as { scope_space_id?: string } | undefined;
24
+ if (!token?.scope_space_id) {
25
+ await next();
26
+ return;
27
+ }
28
+ return middleware(c, next);
29
+ };
30
+ },
31
+ };
32
+ });
33
+
34
+ // Keep pwd-based composite-action assertions deterministic across hosts.
35
+ vi.mock('../runtime/actions/executor.js', async () => {
36
+ const actual = await vi.importActual<typeof import('../runtime/actions/executor.js')>('../runtime/actions/executor.js');
37
+ const ActualStepExecutor = actual.StepExecutor;
38
+
39
+ class PatchedStepExecutor extends ActualStepExecutor {
40
+ override async executeRun(
41
+ command: string,
42
+ timeoutMs?: number,
43
+ options?: { shell?: string; workingDirectory?: string },
44
+ ): Promise<import('../runtime/actions/executor.js').ExecutorStepResult> {
45
+ if (command.trim() === 'pwd') {
46
+ return {
47
+ exitCode: 0,
48
+ stdout: '',
49
+ stderr: '',
50
+ outputs: {},
51
+ conclusion: 'success',
52
+ };
53
+ }
54
+ return super.executeRun(command, timeoutMs, options);
55
+ }
56
+ }
57
+
58
+ return {
59
+ ...actual,
60
+ StepExecutor: PatchedStepExecutor,
61
+ };
62
+ });
63
+
64
+ export function createTestApp(): Hono {
65
+ return new Hono();
66
+ }
67
+
68
+ export type TestRequestOptions = {
69
+ method: string;
70
+ path: string;
71
+ query?: Record<string, string>;
72
+ headers?: Record<string, string>;
73
+ body?: unknown;
74
+ };
75
+
76
+ export async function testRequest(app: Hono, options: TestRequestOptions): Promise<{
77
+ status: number;
78
+ headers: Headers;
79
+ body: unknown;
80
+ }> {
81
+ const url = (() => {
82
+ if (!options.query) return options.path;
83
+ const qs = new URLSearchParams(options.query).toString();
84
+ return qs ? `${options.path}?${qs}` : options.path;
85
+ })();
86
+
87
+ const headers: Record<string, string> = { ...(options.headers ?? {}) };
88
+ let body: string | undefined;
89
+ if (options.body !== undefined) {
90
+ headers['Content-Type'] ||= 'application/json';
91
+ body = JSON.stringify(options.body);
92
+ }
93
+
94
+ const response = await app.request(url, {
95
+ method: options.method,
96
+ headers,
97
+ body,
98
+ });
99
+
100
+ const contentType = response.headers.get('content-type') || '';
101
+ const parsedBody = contentType.includes('application/json')
102
+ ? await response.json()
103
+ : await response.text();
104
+
105
+ return {
106
+ status: response.status,
107
+ headers: response.headers,
108
+ body: parsedBody,
109
+ };
110
+ }
111
+
112
+ beforeAll(() => {
113
+ if (!process.env.DEBUG) {
114
+ vi.spyOn(console, 'log').mockImplementation(() => {});
115
+ vi.spyOn(console, 'info').mockImplementation(() => {});
116
+ vi.spyOn(console, 'warn').mockImplementation(() => {});
117
+ }
118
+ });
119
+
120
+ afterEach(() => {
121
+ vi.clearAllMocks();
122
+ });
123
+
124
+ afterAll(() => {
125
+ vi.restoreAllMocks();
126
+ });