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
package/package.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "takos-runtime-service",
3
+ "version": "1.0.0",
4
+ "type": "module",
5
+ "scripts": {
6
+ "build": "tsc -p tsconfig.json",
7
+ "test": "vitest run",
8
+ "typecheck": "tsc --noEmit"
9
+ },
10
+ "dependencies": {
11
+ "@aws-sdk/client-s3": "^3.700.0",
12
+ "@hono/node-server": "^1.13.0",
13
+ "takos-common": "workspace:*",
14
+ "hono": "^4.12.4",
15
+ "yaml": "^2.8.2"
16
+ },
17
+ "exports": {
18
+ ".": "./src/index.ts",
19
+ "./package.json": "./package.json"
20
+ },
21
+ "files": [
22
+ "src",
23
+ "package.json"
24
+ ],
25
+ "devDependencies": {
26
+ "typescript": "^5.7.0",
27
+ "vitest": "^2.1.0"
28
+ }
29
+ }
@@ -0,0 +1,33 @@
1
+ import { Hono } from 'hono';
2
+ import { describe, expect, it } from 'vitest';
3
+ import { createRateLimiter } from '../../middleware/rate-limit.js';
4
+
5
+ describe('createRateLimiter maxKeys saturation', () => {
6
+ it('fails closed when the key store is saturated', async () => {
7
+ const limiter = createRateLimiter({
8
+ maxRequests: 5,
9
+ windowMs: 60_000,
10
+ maxKeys: 1,
11
+ keyFn: (c) => c.req.header('x-rate-key') || 'unknown',
12
+ });
13
+
14
+ const app = new Hono();
15
+ app.use(limiter);
16
+ app.get('/', (c) => c.json({ ok: true }));
17
+
18
+ const first = await app.request('/', {
19
+ headers: { 'x-rate-key': 'key-1' },
20
+ });
21
+ expect(first.status).toBe(200);
22
+
23
+ const second = await app.request('/', {
24
+ headers: { 'x-rate-key': 'key-2' },
25
+ });
26
+ expect(second.status).toBe(429);
27
+ expect(await second.json()).toEqual({
28
+ error: 'Rate limiter capacity reached. Please try again later.',
29
+ retry_after_seconds: 60,
30
+ });
31
+ expect(second.headers.get('retry-after')).toBe('60');
32
+ });
33
+ });
@@ -0,0 +1,163 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import {
3
+ getSpaceIdFromPath,
4
+ collectRequestedSpaceIds,
5
+ getScopedSpaceId,
6
+ hasSpaceScopeMismatch,
7
+ hasAnySpaceScopeMismatch,
8
+ SPACE_SCOPE_MISMATCH_ERROR,
9
+ } from '../../middleware/space-scope.js';
10
+
11
+ function createContext(overrides: {
12
+ path?: string;
13
+ serviceToken?: { scope_space_id?: string } | null;
14
+ parsedBody?: Record<string, unknown>;
15
+ } = {}) {
16
+ const { path = '/repos/ws1/myrepo', serviceToken = null, parsedBody } = overrides;
17
+ return {
18
+ req: {
19
+ path,
20
+ header: () => undefined,
21
+ },
22
+ get(key: string) {
23
+ if (key === 'serviceToken') return serviceToken;
24
+ if (key === 'parsedBody') return parsedBody;
25
+ return undefined;
26
+ },
27
+ };
28
+ }
29
+
30
+ // ---------------------------------------------------------------------------
31
+ // getSpaceIdFromPath
32
+ // ---------------------------------------------------------------------------
33
+
34
+ describe('getSpaceIdFromPath', () => {
35
+ it('extracts workspace ID from /repos/:spaceId/:repo path', () => {
36
+ const c = createContext({ path: '/repos/ws1/myrepo' });
37
+ expect(getSpaceIdFromPath(c as any)).toBe('ws1');
38
+ });
39
+
40
+ it('extracts workspace ID from deeper paths', () => {
41
+ const c = createContext({ path: '/repos/ws1/myrepo/branches' });
42
+ expect(getSpaceIdFromPath(c as any)).toBe('ws1');
43
+ });
44
+
45
+ it('returns null for non-repos path', () => {
46
+ const c = createContext({ path: '/api/health' });
47
+ expect(getSpaceIdFromPath(c as any)).toBeNull();
48
+ });
49
+
50
+ it('returns null for too-short repos path', () => {
51
+ const c = createContext({ path: '/repos/ws1' });
52
+ expect(getSpaceIdFromPath(c as any)).toBeNull();
53
+ });
54
+
55
+ it('returns null for empty repos path', () => {
56
+ const c = createContext({ path: '/repos' });
57
+ expect(getSpaceIdFromPath(c as any)).toBeNull();
58
+ });
59
+ });
60
+
61
+ // ---------------------------------------------------------------------------
62
+ // collectRequestedSpaceIds
63
+ // ---------------------------------------------------------------------------
64
+
65
+ describe('collectRequestedSpaceIds', () => {
66
+ it('returns unique non-empty strings', () => {
67
+ expect(collectRequestedSpaceIds(['ws1', 'ws2', 'ws1'])).toEqual(['ws1', 'ws2']);
68
+ });
69
+
70
+ it('filters out non-string values', () => {
71
+ expect(collectRequestedSpaceIds([null, undefined, 123, 'ws1'])).toEqual(['ws1']);
72
+ });
73
+
74
+ it('filters out empty strings', () => {
75
+ expect(collectRequestedSpaceIds(['', 'ws1', ''])).toEqual(['ws1']);
76
+ });
77
+
78
+ it('returns empty array for all-invalid input', () => {
79
+ expect(collectRequestedSpaceIds([null, undefined, '', 0])).toEqual([]);
80
+ });
81
+ });
82
+
83
+ // ---------------------------------------------------------------------------
84
+ // getScopedSpaceId
85
+ // ---------------------------------------------------------------------------
86
+
87
+ describe('getScopedSpaceId', () => {
88
+ it('returns scope_space_id from service token', () => {
89
+ const c = createContext({ serviceToken: { scope_space_id: 'ws1' } });
90
+ expect(getScopedSpaceId(c as any)).toBe('ws1');
91
+ });
92
+
93
+ it('returns undefined when no service token', () => {
94
+ const c = createContext({ serviceToken: null });
95
+ expect(getScopedSpaceId(c as any)).toBeUndefined();
96
+ });
97
+
98
+ it('returns undefined when scope_space_id is not a string', () => {
99
+ const c = createContext({ serviceToken: { scope_space_id: 123 } as any });
100
+ expect(getScopedSpaceId(c as any)).toBeUndefined();
101
+ });
102
+ });
103
+
104
+ // ---------------------------------------------------------------------------
105
+ // hasSpaceScopeMismatch
106
+ // ---------------------------------------------------------------------------
107
+
108
+ describe('hasSpaceScopeMismatch', () => {
109
+ it('returns false when no service token', () => {
110
+ const c = createContext({ serviceToken: null });
111
+ expect(hasSpaceScopeMismatch(c as any, 'ws1')).toBe(false);
112
+ });
113
+
114
+ it('returns false when spaceId matches scope', () => {
115
+ const c = createContext({ serviceToken: { scope_space_id: 'ws1' } });
116
+ expect(hasSpaceScopeMismatch(c as any, 'ws1')).toBe(false);
117
+ });
118
+
119
+ it('returns true when spaceId does not match scope', () => {
120
+ const c = createContext({ serviceToken: { scope_space_id: 'ws1' } });
121
+ expect(hasSpaceScopeMismatch(c as any, 'ws2')).toBe(true);
122
+ });
123
+
124
+ it('returns false when spaceId is empty/null/undefined', () => {
125
+ const c = createContext({ serviceToken: { scope_space_id: 'ws1' } });
126
+ expect(hasSpaceScopeMismatch(c as any, '')).toBe(false);
127
+ expect(hasSpaceScopeMismatch(c as any, null)).toBe(false);
128
+ expect(hasSpaceScopeMismatch(c as any, undefined)).toBe(false);
129
+ });
130
+ });
131
+
132
+ // ---------------------------------------------------------------------------
133
+ // hasAnySpaceScopeMismatch
134
+ // ---------------------------------------------------------------------------
135
+
136
+ describe('hasAnySpaceScopeMismatch', () => {
137
+ it('returns false when all match', () => {
138
+ const c = createContext({ serviceToken: { scope_space_id: 'ws1' } });
139
+ expect(hasAnySpaceScopeMismatch(c as any, ['ws1', 'ws1'])).toBe(false);
140
+ });
141
+
142
+ it('returns true when any mismatch', () => {
143
+ const c = createContext({ serviceToken: { scope_space_id: 'ws1' } });
144
+ expect(hasAnySpaceScopeMismatch(c as any, ['ws1', 'ws2'])).toBe(true);
145
+ });
146
+
147
+ it('returns false for empty array', () => {
148
+ const c = createContext({ serviceToken: { scope_space_id: 'ws1' } });
149
+ expect(hasAnySpaceScopeMismatch(c as any, [])).toBe(false);
150
+ });
151
+ });
152
+
153
+ // ---------------------------------------------------------------------------
154
+ // SPACE_SCOPE_MISMATCH_ERROR
155
+ // ---------------------------------------------------------------------------
156
+
157
+ describe('SPACE_SCOPE_MISMATCH_ERROR', () => {
158
+ it('is the expected string', () => {
159
+ expect(SPACE_SCOPE_MISMATCH_ERROR).toBe(
160
+ 'Token workspace scope does not match requested workspace',
161
+ );
162
+ });
163
+ });
@@ -0,0 +1,139 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { createTestApp, testRequest } from '../setup.js';
3
+
4
+ vi.hoisted(() => {
5
+ process.env.TAKOS_API_URL = 'https://takos.example.test';
6
+ });
7
+
8
+ import actionsRoutes from '../../routes/actions/index.js';
9
+ import { SANDBOX_LIMITS } from '../../shared/config.js';
10
+
11
+ function createStartBody(jobName: string, spaceId: string, stepCount = 1) {
12
+ return {
13
+ space_id: spaceId,
14
+ repoId: 'acme/repo',
15
+ ref: 'refs/heads/main',
16
+ sha: 'a'.repeat(40),
17
+ workflowPath: '.takos/workflows/ci.yml',
18
+ jobName,
19
+ steps: Array.from({ length: stepCount }, (_, i) => ({
20
+ name: `noop-${i + 1}`,
21
+ run: 'echo hello',
22
+ })),
23
+ };
24
+ }
25
+
26
+ describe('actions start step limits', () => {
27
+ it('rejects start when step count exceeds maxStepsPerJob', async () => {
28
+ const app = createTestApp();
29
+ app.route('/', actionsRoutes);
30
+
31
+ const jobId = `steps-over-limit-${Date.now()}`;
32
+ const response = await testRequest(app, {
33
+ method: 'POST',
34
+ path: `/actions/jobs/${jobId}/start`,
35
+ body: createStartBody(
36
+ 'over-limit',
37
+ 'workspace-step-limit',
38
+ SANDBOX_LIMITS.maxStepsPerJob + 1,
39
+ ),
40
+ });
41
+
42
+ expect(response.status).toBe(400);
43
+ expect(response.body).toEqual({
44
+ error: {
45
+ code: 'BAD_REQUEST',
46
+ message: `Steps exceed per-job limit (max ${SANDBOX_LIMITS.maxStepsPerJob})`,
47
+ },
48
+ });
49
+ });
50
+
51
+ it('accepts start when step count equals maxStepsPerJob', async () => {
52
+ const app = createTestApp();
53
+ app.route('/', actionsRoutes);
54
+
55
+ const jobId = `steps-at-limit-${Date.now()}`;
56
+
57
+ try {
58
+ const response = await testRequest(app, {
59
+ method: 'POST',
60
+ path: `/actions/jobs/${jobId}/start`,
61
+ body: createStartBody(
62
+ 'at-limit',
63
+ 'workspace-step-limit',
64
+ SANDBOX_LIMITS.maxStepsPerJob,
65
+ ),
66
+ });
67
+
68
+ expect(response.status).toBe(200);
69
+ expect(response.body).toMatchObject({
70
+ jobId,
71
+ status: 'running',
72
+ message: 'Job started successfully',
73
+ });
74
+ } finally {
75
+ await testRequest(app, {
76
+ method: 'DELETE',
77
+ path: `/actions/jobs/${jobId}`,
78
+ });
79
+ }
80
+ });
81
+ });
82
+
83
+ describe('actions start concurrency limits', () => {
84
+ it('applies maxConcurrentJobs per workspace', async () => {
85
+ const app = createTestApp();
86
+ app.route('/', actionsRoutes);
87
+
88
+ const startedJobIds: string[] = [];
89
+ const prefix = `concurrency-${Date.now()}`;
90
+ const workspaceA = 'workspace-a';
91
+ const workspaceB = 'workspace-b';
92
+
93
+ try {
94
+ for (let i = 0; i < SANDBOX_LIMITS.maxConcurrentJobs; i++) {
95
+ const jobId = `${prefix}-${i}`;
96
+ const response = await testRequest(app, {
97
+ method: 'POST',
98
+ path: `/actions/jobs/${jobId}/start`,
99
+ body: createStartBody(`job-${i}`, workspaceA),
100
+ });
101
+
102
+ expect(response.status).toBe(200);
103
+ startedJobIds.push(jobId);
104
+ }
105
+
106
+ const otherWorkspaceJobId = `${prefix}-other-workspace`;
107
+ const otherWorkspaceResponse = await testRequest(app, {
108
+ method: 'POST',
109
+ path: `/actions/jobs/${otherWorkspaceJobId}/start`,
110
+ body: createStartBody('job-other-workspace', workspaceB),
111
+ });
112
+
113
+ expect(otherWorkspaceResponse.status).toBe(200);
114
+ startedJobIds.push(otherWorkspaceJobId);
115
+
116
+ const overflowJobId = `${prefix}-overflow`;
117
+ const overflowResponse = await testRequest(app, {
118
+ method: 'POST',
119
+ path: `/actions/jobs/${overflowJobId}/start`,
120
+ body: createStartBody('overflow-job', workspaceA),
121
+ });
122
+
123
+ expect(overflowResponse.status).toBe(429);
124
+ expect(overflowResponse.body).toEqual({
125
+ error: {
126
+ code: 'RATE_LIMITED',
127
+ message: `Concurrent job limit reached (max ${SANDBOX_LIMITS.maxConcurrentJobs})`,
128
+ },
129
+ });
130
+ } finally {
131
+ for (const jobId of startedJobIds) {
132
+ await testRequest(app, {
133
+ method: 'DELETE',
134
+ path: `/actions/jobs/${jobId}`,
135
+ });
136
+ }
137
+ }
138
+ });
139
+ });
@@ -0,0 +1,194 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import { createTestApp, testRequest } from '../setup.js';
3
+
4
+ vi.hoisted(() => {
5
+ process.env.TAKOS_API_URL = 'https://takos.example.test';
6
+ });
7
+
8
+ const { executeRunMock, executeActionMock, capturedStepEnvs } = vi.hoisted(() => ({
9
+ executeRunMock: vi.fn(),
10
+ executeActionMock: vi.fn(),
11
+ capturedStepEnvs: [] as Array<Record<string, string>>,
12
+ }));
13
+
14
+ vi.mock('../../runtime/actions/executor.js', () => ({
15
+ StepExecutor: class {
16
+ constructor(_workspacePath: string, env: Record<string, string>) {
17
+ capturedStepEnvs.push(env);
18
+ }
19
+
20
+ executeRun = executeRunMock;
21
+
22
+ executeAction = executeActionMock;
23
+ },
24
+ }));
25
+
26
+ import actionsRoutes from '../../routes/actions/index.js';
27
+
28
+ describe('actions step behavior', () => {
29
+ beforeEach(() => {
30
+ executeRunMock.mockResolvedValue({
31
+ exitCode: 0,
32
+ stdout: 'ok',
33
+ stderr: '',
34
+ outputs: {},
35
+ conclusion: 'success',
36
+ });
37
+ executeActionMock.mockResolvedValue({
38
+ exitCode: 0,
39
+ stdout: '',
40
+ stderr: '',
41
+ outputs: {},
42
+ conclusion: 'success',
43
+ });
44
+ capturedStepEnvs.length = 0;
45
+ });
46
+
47
+ it('keeps secrets masked for printenv-like commands without custom warnings field', async () => {
48
+ const app = createTestApp();
49
+ app.route('/', actionsRoutes);
50
+
51
+ executeRunMock.mockResolvedValue({
52
+ exitCode: 0,
53
+ stdout: 'token=s3cr3t',
54
+ stderr: '',
55
+ outputs: {},
56
+ conclusion: 'success',
57
+ });
58
+
59
+ const jobId = `step-mask-${Date.now()}`;
60
+
61
+ try {
62
+ const startResponse = await testRequest(app, {
63
+ method: 'POST',
64
+ path: `/actions/jobs/${jobId}/start`,
65
+ body: {
66
+ space_id: 'workspace-step-mask',
67
+ repoId: 'acme/repo',
68
+ ref: 'refs/heads/main',
69
+ sha: 'a'.repeat(40),
70
+ workflowPath: '.takos/workflows/ci.yml',
71
+ jobName: 'mask-job',
72
+ secrets: {
73
+ TOKEN: 's3cr3t',
74
+ },
75
+ steps: [{ name: 'step-1', run: 'echo hello' }],
76
+ },
77
+ });
78
+
79
+ expect(startResponse.status).toBe(200);
80
+
81
+ const stepResponse = await testRequest(app, {
82
+ method: 'POST',
83
+ path: `/actions/jobs/${jobId}/step/0`,
84
+ body: {
85
+ name: 'step-1',
86
+ run: 'echo hello',
87
+ },
88
+ });
89
+
90
+ expect(stepResponse.status).toBe(200);
91
+ expect(stepResponse.body).toMatchObject({
92
+ conclusion: 'success',
93
+ stdout: 'token=***',
94
+ });
95
+ expect(stepResponse.body).not.toHaveProperty('warnings');
96
+
97
+ const logsResponse = await testRequest(app, {
98
+ method: 'GET',
99
+ path: `/actions/jobs/${jobId}/logs`,
100
+ });
101
+ const logsBody = logsResponse.body as { logs: string[] };
102
+ expect(logsResponse.status).toBe(200);
103
+ expect(logsBody.logs.some((line) => line.includes('token=***'))).toBe(true);
104
+ expect(logsBody.logs.some((line) => line.includes('s3cr3t'))).toBe(false);
105
+ } finally {
106
+ await testRequest(app, {
107
+ method: 'DELETE',
108
+ path: `/actions/jobs/${jobId}`,
109
+ });
110
+ }
111
+ });
112
+
113
+ it('always sets GITHUB_RUN_ID to jobId', async () => {
114
+ const app = createTestApp();
115
+ app.route('/', actionsRoutes);
116
+
117
+ const jobId = `with-run-id-${Date.now()}`;
118
+
119
+ try {
120
+ const startResponse = await testRequest(app, {
121
+ method: 'POST',
122
+ path: `/actions/jobs/${jobId}/start`,
123
+ body: {
124
+ space_id: 'workspace-run-id',
125
+ repoId: 'acme/repo',
126
+ ref: 'refs/heads/main',
127
+ sha: 'a'.repeat(40),
128
+ workflowPath: '.takos/workflows/ci.yml',
129
+ jobName: 'run-id-job',
130
+ steps: [{ name: 'step-1', run: 'echo hello' }],
131
+ },
132
+ });
133
+ expect(startResponse.status).toBe(200);
134
+
135
+ const stepResponse = await testRequest(app, {
136
+ method: 'POST',
137
+ path: `/actions/jobs/${jobId}/step/0`,
138
+ body: {
139
+ name: 'step-1',
140
+ run: 'echo hello',
141
+ },
142
+ });
143
+
144
+ expect(stepResponse.status).toBe(200);
145
+ expect(capturedStepEnvs.at(-1)?.GITHUB_RUN_ID).toBe(jobId);
146
+ } finally {
147
+ await testRequest(app, {
148
+ method: 'DELETE',
149
+ path: `/actions/jobs/${jobId}`,
150
+ });
151
+ }
152
+ });
153
+
154
+ it('falls back to jobId for GITHUB_RUN_ID when runId is omitted', async () => {
155
+ const app = createTestApp();
156
+ app.route('/', actionsRoutes);
157
+
158
+ const jobId = `fallback-run-id-${Date.now()}`;
159
+
160
+ try {
161
+ const startResponse = await testRequest(app, {
162
+ method: 'POST',
163
+ path: `/actions/jobs/${jobId}/start`,
164
+ body: {
165
+ space_id: 'workspace-run-id-fallback',
166
+ repoId: 'acme/repo',
167
+ ref: 'refs/heads/main',
168
+ sha: 'a'.repeat(40),
169
+ workflowPath: '.takos/workflows/ci.yml',
170
+ jobName: 'fallback-run-id-job',
171
+ steps: [{ name: 'step-1', run: 'echo hello' }],
172
+ },
173
+ });
174
+ expect(startResponse.status).toBe(200);
175
+
176
+ const stepResponse = await testRequest(app, {
177
+ method: 'POST',
178
+ path: `/actions/jobs/${jobId}/step/0`,
179
+ body: {
180
+ name: 'step-1',
181
+ run: 'echo hello',
182
+ },
183
+ });
184
+
185
+ expect(stepResponse.status).toBe(200);
186
+ expect(capturedStepEnvs.at(-1)?.GITHUB_RUN_ID).toBe(jobId);
187
+ } finally {
188
+ await testRequest(app, {
189
+ method: 'DELETE',
190
+ path: `/actions/jobs/${jobId}`,
191
+ });
192
+ }
193
+ });
194
+ });
@@ -0,0 +1,72 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import { createTestApp, testRequest } from '../setup.js';
3
+
4
+ const hoisted = vi.hoisted(() => {
5
+ process.env.TAKOS_API_URL = 'https://takos.example.test';
6
+ process.env.PROXY_BASE_URL = 'https://runtime-host.example.test';
7
+ return {
8
+ getSession: vi.fn(),
9
+ fetch: vi.fn(),
10
+ };
11
+ });
12
+
13
+ vi.mock('../../routes/sessions/storage.js', () => ({
14
+ sessionStore: {
15
+ getSession: hoisted.getSession,
16
+ },
17
+ }));
18
+
19
+ import cliProxyRoutes from '../../routes/cli/proxy.js';
20
+
21
+ describe('cli-proxy route', () => {
22
+ beforeEach(() => {
23
+ hoisted.getSession.mockReset();
24
+ hoisted.fetch.mockReset();
25
+ vi.stubGlobal('fetch', hoisted.fetch);
26
+ });
27
+
28
+ afterEach(() => {
29
+ vi.unstubAllGlobals();
30
+ });
31
+
32
+ it('forwards query parameters via PROXY_BASE_URL while validating the path only', async () => {
33
+ const app = createTestApp();
34
+ app.route('/', cliProxyRoutes);
35
+
36
+ hoisted.getSession.mockReturnValue({
37
+ id: 'a12345678901234b',
38
+ spaceId: 'space-a',
39
+ proxyToken: 'random-proxy-token-abc',
40
+ lastAccessedAt: 0,
41
+ });
42
+ hoisted.fetch.mockResolvedValue({
43
+ status: 200,
44
+ text: async () => JSON.stringify({ ok: true }),
45
+ });
46
+
47
+ const response = await testRequest(app, {
48
+ method: 'GET',
49
+ path: '/cli-proxy/api/repos/repo-1/status',
50
+ query: {
51
+ ref: 'refs/heads/main',
52
+ verbose: '1',
53
+ },
54
+ headers: {
55
+ 'X-Takos-Session-Id': 'a12345678901234b',
56
+ 'X-Takos-Space-Id': 'space-a',
57
+ },
58
+ });
59
+
60
+ expect(response.status).toBe(200);
61
+ expect(response.body).toEqual({ ok: true });
62
+
63
+ expect(hoisted.fetch).toHaveBeenCalledTimes(1);
64
+ const forwardedUrl = hoisted.fetch.mock.calls[0]?.[0] as string;
65
+ const forwardedOptions = hoisted.fetch.mock.calls[0]?.[1] as { headers: Record<string, string>; method: string };
66
+
67
+ expect(forwardedUrl).toBe('https://runtime-host.example.test/forward/cli-proxy/api/repos/repo-1/status?ref=refs%2Fheads%2Fmain&verbose=1');
68
+ expect(forwardedOptions.method).toBe('GET');
69
+ expect(forwardedOptions.headers['X-Takos-Session-Id']).toBe('a12345678901234b');
70
+ expect(forwardedOptions.headers.Authorization).toBe('Bearer random-proxy-token-abc');
71
+ });
72
+ });