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,117 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import {
3
+ SymlinkEscapeError,
4
+ SymlinkNotAllowedError,
5
+ SymlinkWriteError,
6
+ OwnerBindingError,
7
+ isBoundaryViolationError,
8
+ } from '../../shared/errors.js';
9
+
10
+ // ---------------------------------------------------------------------------
11
+ // SymlinkEscapeError
12
+ // ---------------------------------------------------------------------------
13
+
14
+ describe('SymlinkEscapeError', () => {
15
+ it('sets correct name and message', () => {
16
+ const err = new SymlinkEscapeError('workspace');
17
+ expect(err.name).toBe('SymlinkEscapeError');
18
+ expect(err.message).toBe('Symlink escape detected in workspace path');
19
+ });
20
+
21
+ it('is instanceof Error', () => {
22
+ const err = new SymlinkEscapeError('test');
23
+ expect(err).toBeInstanceOf(Error);
24
+ });
25
+ });
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // SymlinkNotAllowedError
29
+ // ---------------------------------------------------------------------------
30
+
31
+ describe('SymlinkNotAllowedError', () => {
32
+ it('sets message with label', () => {
33
+ const err = new SymlinkNotAllowedError('file');
34
+ expect(err.name).toBe('SymlinkNotAllowedError');
35
+ expect(err.message).toBe('Symlinks are not allowed in file path');
36
+ });
37
+
38
+ it('sets default message without label', () => {
39
+ const err = new SymlinkNotAllowedError();
40
+ expect(err.message).toBe('Symlinks are not allowed');
41
+ });
42
+
43
+ it('is instanceof Error', () => {
44
+ const err = new SymlinkNotAllowedError();
45
+ expect(err).toBeInstanceOf(Error);
46
+ });
47
+ });
48
+
49
+ // ---------------------------------------------------------------------------
50
+ // SymlinkWriteError
51
+ // ---------------------------------------------------------------------------
52
+
53
+ describe('SymlinkWriteError', () => {
54
+ it('sets correct name and message', () => {
55
+ const err = new SymlinkWriteError();
56
+ expect(err.name).toBe('SymlinkWriteError');
57
+ expect(err.message).toBe('Cannot write to symlinks');
58
+ });
59
+
60
+ it('is instanceof Error', () => {
61
+ expect(new SymlinkWriteError()).toBeInstanceOf(Error);
62
+ });
63
+ });
64
+
65
+ // ---------------------------------------------------------------------------
66
+ // OwnerBindingError
67
+ // ---------------------------------------------------------------------------
68
+
69
+ describe('OwnerBindingError', () => {
70
+ it('sets default message', () => {
71
+ const err = new OwnerBindingError();
72
+ expect(err.name).toBe('OwnerBindingError');
73
+ expect(err.message).toBe('Session does not belong to the authenticated owner');
74
+ });
75
+
76
+ it('accepts custom message', () => {
77
+ const err = new OwnerBindingError('Custom error message');
78
+ expect(err.message).toBe('Custom error message');
79
+ });
80
+
81
+ it('is instanceof Error', () => {
82
+ expect(new OwnerBindingError()).toBeInstanceOf(Error);
83
+ });
84
+ });
85
+
86
+ // ---------------------------------------------------------------------------
87
+ // isBoundaryViolationError
88
+ // ---------------------------------------------------------------------------
89
+
90
+ describe('isBoundaryViolationError', () => {
91
+ it('returns true for SymlinkEscapeError', () => {
92
+ expect(isBoundaryViolationError(new SymlinkEscapeError('test'))).toBe(true);
93
+ });
94
+
95
+ it('returns true for SymlinkNotAllowedError', () => {
96
+ expect(isBoundaryViolationError(new SymlinkNotAllowedError())).toBe(true);
97
+ });
98
+
99
+ it('returns true for SymlinkWriteError', () => {
100
+ expect(isBoundaryViolationError(new SymlinkWriteError())).toBe(true);
101
+ });
102
+
103
+ it('returns false for regular Error', () => {
104
+ expect(isBoundaryViolationError(new Error('plain error'))).toBe(false);
105
+ });
106
+
107
+ it('returns false for non-error values', () => {
108
+ expect(isBoundaryViolationError(null)).toBe(false);
109
+ expect(isBoundaryViolationError(undefined)).toBe(false);
110
+ expect(isBoundaryViolationError('string')).toBe(false);
111
+ expect(isBoundaryViolationError(42)).toBe(false);
112
+ });
113
+
114
+ it('returns false for OwnerBindingError', () => {
115
+ expect(isBoundaryViolationError(new OwnerBindingError())).toBe(false);
116
+ });
117
+ });
@@ -0,0 +1,106 @@
1
+ import * as fs from 'fs/promises';
2
+ import * as os from 'os';
3
+ import path from 'path';
4
+ import { afterEach, describe, expect, it, vi } from 'vitest';
5
+
6
+ vi.mock('../../shared/config.js', () => ({
7
+ R2_ACCOUNT_ID: 'test-account',
8
+ R2_ACCESS_KEY_ID: 'test-access-key',
9
+ R2_SECRET_ACCESS_KEY: 'test-secret',
10
+ R2_BUCKET: 'test-bucket',
11
+ S3_ENDPOINT: 'http://127.0.0.1:9000',
12
+ S3_REGION: 'us-east-1',
13
+ S3_ACCESS_KEY_ID: 'test-access-key',
14
+ S3_SECRET_ACCESS_KEY: 'test-secret',
15
+ S3_BUCKET: 'test-bucket',
16
+ MAX_R2_DOWNLOAD_FILE_BYTES: 1024 * 1024,
17
+ MAX_R2_DOWNLOAD_TOTAL_BYTES: 1024 * 1024 * 4,
18
+ MAX_LOG_LINES: 100_000,
19
+ }));
20
+
21
+ import { downloadSpaceFiles, uploadSpaceFiles, s3Client } from '../../storage/r2.js';
22
+
23
+ async function createTempDir(prefix: string): Promise<string> {
24
+ return fs.mkdtemp(path.join(os.tmpdir(), prefix));
25
+ }
26
+
27
+ describe('r2 symlink boundary hardening', () => {
28
+ afterEach(() => {
29
+ vi.restoreAllMocks();
30
+ });
31
+
32
+ it('skips upload paths that symlink outside base directory', async () => {
33
+ const workspaceDir = await createTempDir('takos-r2-upload-ws-');
34
+ const outsideDir = await createTempDir('takos-r2-upload-outside-');
35
+ const outsideFile = path.join(outsideDir, 'outside.txt');
36
+ const symlinkPath = path.join(workspaceDir, 'escape.txt');
37
+
38
+ try {
39
+ await fs.writeFile(outsideFile, 'outside');
40
+ await fs.symlink(outsideFile, symlinkPath);
41
+
42
+ const sendSpy = vi.spyOn(s3Client, 'send');
43
+ const logs: string[] = [];
44
+
45
+ const uploaded = await uploadSpaceFiles('ws-upload', workspaceDir, ['escape.txt'], logs);
46
+
47
+ expect(uploaded).toBe(0);
48
+ expect(sendSpy).not.toHaveBeenCalled();
49
+ expect(logs.some((line) => line.includes('symlink escape attempt'))).toBe(true);
50
+ } finally {
51
+ await fs.rm(workspaceDir, { recursive: true, force: true });
52
+ await fs.rm(outsideDir, { recursive: true, force: true });
53
+ }
54
+ });
55
+
56
+ it('skips download paths that traverse through escaping symlink components', async () => {
57
+ const workspaceDir = await createTempDir('takos-r2-download-ws-');
58
+ const outsideDir = await createTempDir('takos-r2-download-outside-');
59
+ const escapeLink = path.join(workspaceDir, 'escape-dir');
60
+
61
+ try {
62
+ await fs.symlink(outsideDir, escapeLink);
63
+
64
+ vi.spyOn(s3Client, 'send').mockImplementation(async (command: object) => {
65
+ const commandName = (command as { constructor?: { name?: string } }).constructor?.name;
66
+
67
+ if (commandName === 'ListObjectsV2Command') {
68
+ return {
69
+ Contents: [
70
+ {
71
+ Key: 'workspaces/ws-download/files/object-1',
72
+ Size: 5,
73
+ },
74
+ ],
75
+ NextContinuationToken: undefined,
76
+ };
77
+ }
78
+
79
+ if (commandName === 'GetObjectCommand') {
80
+ return {
81
+ Body: {
82
+ transformToByteArray: async () => Buffer.from('hello'),
83
+ },
84
+ Metadata: {
85
+ 'file-path': 'escape-dir/evil.txt',
86
+ },
87
+ };
88
+ }
89
+
90
+ throw new Error(`Unexpected command: ${commandName}`);
91
+ });
92
+
93
+ const logs: string[] = [];
94
+ const downloaded = await downloadSpaceFiles('ws-download', workspaceDir, logs);
95
+ const outsideFile = path.join(outsideDir, 'evil.txt');
96
+ const outsideFileExists = await fs.stat(outsideFile).then(() => true).catch(() => false);
97
+
98
+ expect(downloaded).toBe(0);
99
+ expect(outsideFileExists).toBe(false);
100
+ expect(logs.some((line) => line.includes('symlink escape attempt'))).toBe(true);
101
+ } finally {
102
+ await fs.rm(workspaceDir, { recursive: true, force: true });
103
+ await fs.rm(outsideDir, { recursive: true, force: true });
104
+ }
105
+ });
106
+ });
@@ -0,0 +1,163 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+
3
+ // Mock fs/promises and logger before importing the module under test.
4
+ // Each test uses vi.resetModules() + dynamic import to get fresh module state
5
+ // (the module caches `dirEnsured` at module scope).
6
+
7
+ const mockAppendFile = vi.fn();
8
+ const mockMkdir = vi.fn();
9
+ const mockStat = vi.fn();
10
+ const mockRename = vi.fn();
11
+ const mockUnlink = vi.fn();
12
+
13
+ vi.mock('fs/promises', async () => {
14
+ const actual = await vi.importActual<typeof import('fs/promises')>('fs/promises');
15
+ return {
16
+ ...actual,
17
+ appendFile: (...args: any[]) => mockAppendFile(...args),
18
+ mkdir: (...args: any[]) => mockMkdir(...args),
19
+ stat: (...args: any[]) => mockStat(...args),
20
+ rename: (...args: any[]) => mockRename(...args),
21
+ unlink: (...args: any[]) => mockUnlink(...args),
22
+ };
23
+ });
24
+
25
+ vi.mock('takos-common/logger', () => ({
26
+ createLogger: () => ({
27
+ info: vi.fn(),
28
+ warn: vi.fn(),
29
+ error: vi.fn(),
30
+ debug: vi.fn(),
31
+ }),
32
+ }));
33
+
34
+ async function freshWriteAuditLog() {
35
+ vi.resetModules();
36
+ const mod = await import('../../utils/audit-log.js');
37
+ return mod.writeAuditLog;
38
+ }
39
+
40
+ beforeEach(() => {
41
+ vi.clearAllMocks();
42
+ mockMkdir.mockResolvedValue(undefined);
43
+ mockStat.mockRejectedValue(new Error('ENOENT'));
44
+ mockAppendFile.mockResolvedValue(undefined);
45
+ });
46
+
47
+ describe('writeAuditLog', () => {
48
+ it('writes audit entry as JSONL', async () => {
49
+ const writeAuditLog = await freshWriteAuditLog();
50
+
51
+ await writeAuditLog({
52
+ timestamp: '2024-01-01T00:00:00Z',
53
+ event: 'exec',
54
+ spaceId: 'ws1',
55
+ command: 'echo hello',
56
+ status: 'completed',
57
+ });
58
+
59
+ expect(mockAppendFile).toHaveBeenCalledOnce();
60
+ const writtenLine = mockAppendFile.mock.calls[0][1] as string;
61
+ expect(writtenLine.endsWith('\n')).toBe(true);
62
+ const parsed = JSON.parse(writtenLine.trim());
63
+ expect(parsed.event).toBe('exec');
64
+ expect(parsed.spaceId).toBe('ws1');
65
+ expect(parsed.command).toBe('echo hello');
66
+ });
67
+
68
+ it('redacts credentials in URLs', async () => {
69
+ const writeAuditLog = await freshWriteAuditLog();
70
+
71
+ await writeAuditLog({
72
+ timestamp: '2024-01-01T00:00:00Z',
73
+ event: 'exec',
74
+ spaceId: 'ws1',
75
+ command: 'git clone https://user:password@github.com/repo.git',
76
+ status: 'started',
77
+ });
78
+
79
+ const writtenLine = mockAppendFile.mock.calls[0][1] as string;
80
+ const parsed = JSON.parse(writtenLine.trim());
81
+ expect(parsed.command).not.toContain('password');
82
+ expect(parsed.command).toContain('***@');
83
+ });
84
+
85
+ it('redacts Authorization header values', async () => {
86
+ const writeAuditLog = await freshWriteAuditLog();
87
+
88
+ await writeAuditLog({
89
+ timestamp: '2024-01-01T00:00:00Z',
90
+ event: 'exec',
91
+ spaceId: 'ws1',
92
+ command: 'curl -H "Authorization: Bearer my-secret-token" https://api.com',
93
+ status: 'started',
94
+ });
95
+
96
+ const writtenLine = mockAppendFile.mock.calls[0][1] as string;
97
+ const parsed = JSON.parse(writtenLine.trim());
98
+ expect(parsed.command).not.toContain('my-secret-token');
99
+ expect(parsed.command).toContain('Authorization: ***');
100
+ });
101
+
102
+ it('redacts SECRET_KEY=value patterns', async () => {
103
+ const writeAuditLog = await freshWriteAuditLog();
104
+
105
+ await writeAuditLog({
106
+ timestamp: '2024-01-01T00:00:00Z',
107
+ event: 'exec',
108
+ spaceId: 'ws1',
109
+ command: 'SECRET_KEY=mysecret npm start',
110
+ status: 'started',
111
+ });
112
+
113
+ const writtenLine = mockAppendFile.mock.calls[0][1] as string;
114
+ const parsed = JSON.parse(writtenLine.trim());
115
+ expect(parsed.command).not.toContain('mysecret');
116
+ expect(parsed.command).toContain('SECRET_KEY=***');
117
+ });
118
+
119
+ it('redacts commands array', async () => {
120
+ const writeAuditLog = await freshWriteAuditLog();
121
+
122
+ await writeAuditLog({
123
+ timestamp: '2024-01-01T00:00:00Z',
124
+ event: 'exec',
125
+ spaceId: 'ws1',
126
+ commands: [
127
+ 'curl -H "Authorization: Bearer token1"',
128
+ 'echo TOKEN=secret123',
129
+ ],
130
+ status: 'started',
131
+ });
132
+
133
+ const writtenLine = mockAppendFile.mock.calls[0][1] as string;
134
+ const parsed = JSON.parse(writtenLine.trim());
135
+ expect(parsed.commands[0]).not.toContain('token1');
136
+ expect(parsed.commands[1]).toContain('TOKEN=***');
137
+ });
138
+
139
+ it('does not throw on write failure', async () => {
140
+ mockAppendFile.mockRejectedValue(new Error('write failed'));
141
+ const writeAuditLog = await freshWriteAuditLog();
142
+
143
+ await expect(writeAuditLog({
144
+ timestamp: '2024-01-01T00:00:00Z',
145
+ event: 'exec',
146
+ spaceId: 'ws1',
147
+ status: 'started',
148
+ })).resolves.not.toThrow();
149
+ });
150
+
151
+ it('ensures directory is created on first call', async () => {
152
+ const writeAuditLog = await freshWriteAuditLog();
153
+
154
+ await writeAuditLog({
155
+ timestamp: '2024-01-01T00:00:00Z',
156
+ event: 'exec',
157
+ spaceId: 'ws1',
158
+ status: 'started',
159
+ });
160
+
161
+ expect(mockMkdir).toHaveBeenCalled();
162
+ });
163
+ });
@@ -0,0 +1,38 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { getErrorMessage } from 'takos-common/errors';
3
+
4
+ describe('getErrorMessage', () => {
5
+ it('returns message from Error instance', () => {
6
+ expect(getErrorMessage(new Error('test error'))).toBe('test error');
7
+ });
8
+
9
+ it('returns string representation for non-Error', () => {
10
+ expect(getErrorMessage('string error')).toBe('string error');
11
+ });
12
+
13
+ it('handles number', () => {
14
+ expect(getErrorMessage(42)).toBe('42');
15
+ });
16
+
17
+ it('handles null', () => {
18
+ expect(getErrorMessage(null)).toBe('null');
19
+ });
20
+
21
+ it('handles undefined', () => {
22
+ expect(getErrorMessage(undefined)).toBe('undefined');
23
+ });
24
+
25
+ it('handles object', () => {
26
+ expect(getErrorMessage({ code: 'ERR' })).toBe('[object Object]');
27
+ });
28
+
29
+ it('returns message from custom error class', () => {
30
+ class CustomError extends Error {
31
+ constructor() {
32
+ super('custom message');
33
+ this.name = 'CustomError';
34
+ }
35
+ }
36
+ expect(getErrorMessage(new CustomError())).toBe('custom message');
37
+ });
38
+ });
@@ -0,0 +1,74 @@
1
+ import { afterEach, describe, expect, it } from 'vitest';
2
+ import { createSandboxEnv, validateRuntimeExecEnv } from '../../utils/sandbox-env.js';
3
+
4
+ const originalAwsSecret = process.env.AWS_SECRET_ACCESS_KEY;
5
+
6
+ afterEach(() => {
7
+ if (originalAwsSecret === undefined) {
8
+ delete process.env.AWS_SECRET_ACCESS_KEY;
9
+ return;
10
+ }
11
+ process.env.AWS_SECRET_ACCESS_KEY = originalAwsSecret;
12
+ });
13
+
14
+ describe('validateRuntimeExecEnv', () => {
15
+ it('accepts undefined env as empty object', () => {
16
+ expect(validateRuntimeExecEnv(undefined)).toEqual({ ok: true, env: {} });
17
+ });
18
+
19
+ it('accepts valid env entries', () => {
20
+ const result = validateRuntimeExecEnv({
21
+ CI: 'true',
22
+ MY_FEATURE_FLAG: '1',
23
+ });
24
+ expect(result).toEqual({
25
+ ok: true,
26
+ env: { CI: 'true', MY_FEATURE_FLAG: '1' },
27
+ });
28
+ });
29
+
30
+ it('rejects invalid variable names', () => {
31
+ const result = validateRuntimeExecEnv({
32
+ '1INVALID': 'value',
33
+ });
34
+ expect(result.ok).toBe(false);
35
+ if (!result.ok) {
36
+ expect(result.error).toContain('Invalid environment variable name');
37
+ }
38
+ });
39
+
40
+ it('rejects sensitive variable names', () => {
41
+ const result = validateRuntimeExecEnv({
42
+ TAKOS_TOKEN: 'secret',
43
+ });
44
+ expect(result.ok).toBe(false);
45
+ if (!result.ok) {
46
+ expect(result.error).toContain('Sensitive environment variable is not allowed');
47
+ }
48
+ });
49
+
50
+ it('rejects values with newlines', () => {
51
+ const result = validateRuntimeExecEnv({
52
+ SAFE_NAME: 'line1\nline2',
53
+ });
54
+ expect(result.ok).toBe(false);
55
+ if (!result.ok) {
56
+ expect(result.error).toContain('contains invalid characters');
57
+ }
58
+ });
59
+
60
+ it('keeps explicit workflow env values for allowed prefixes and blocks host secrets', () => {
61
+ process.env.AWS_SECRET_ACCESS_KEY = 'host-secret-value';
62
+
63
+ const sandboxEnv = createSandboxEnv({
64
+ GITHUB_TOKEN: 'token-from-workflow',
65
+ INPUT_SECRET: 'secret-from-workflow',
66
+ RUNNER_TEMP: '/tmp/runner',
67
+ });
68
+
69
+ expect(sandboxEnv.GITHUB_TOKEN).toBe('token-from-workflow');
70
+ expect(sandboxEnv.INPUT_SECRET).toBe('secret-from-workflow');
71
+ expect(sandboxEnv.RUNNER_TEMP).toBe('/tmp/runner');
72
+ expect(sandboxEnv.AWS_SECRET_ACCESS_KEY).toBeUndefined();
73
+ });
74
+ });