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,17 @@
1
+ import { Hono } from 'hono';
2
+ import executionRoutes from './execution.js';
3
+ import jobLifecycleRoutes from './job-lifecycle.js';
4
+ import jobQueryRoutes from './job-queries.js';
5
+
6
+ const app = new Hono();
7
+
8
+ // Mount execution routes (checkout + step)
9
+ app.route('/', executionRoutes);
10
+
11
+ // Mount job lifecycle routes (start, complete, cancel)
12
+ app.route('/', jobLifecycleRoutes);
13
+
14
+ // Mount job query routes (status, logs)
15
+ app.route('/', jobQueryRoutes);
16
+
17
+ export default app;
@@ -0,0 +1,242 @@
1
+ import { Hono } from 'hono';
2
+ import * as fs from 'fs/promises';
3
+ import * as path from 'path';
4
+ import * as os from 'os';
5
+ import { SANDBOX_LIMITS } from '../../shared/config.js';
6
+ import { createSandboxEnv } from '../../utils/sandbox-env.js';
7
+ import { createSecretsSanitizer } from '../../runtime/actions/secrets.js';
8
+ import { pushLog } from '../../runtime/logging.js';
9
+ import { isR2Configured, s3Client } from '../../storage/r2.js';
10
+ import { PutObjectCommand } from '@aws-sdk/client-s3';
11
+ import { R2_BUCKET } from '../../shared/config.js';
12
+ import {
13
+ getScopedSpaceId,
14
+ hasSpaceScopeMismatch,
15
+ SPACE_SCOPE_MISMATCH_ERROR,
16
+ } from '../../middleware/space-scope.js';
17
+ import {
18
+ jobManager,
19
+ removeJobDirSafe,
20
+ } from '../../runtime/actions/job-manager.js';
21
+ import type { ActiveJob } from '../../runtime/actions/job-manager.js';
22
+ import { collectSensitiveEnvValues } from '../../runtime/actions/secrets.js';
23
+ import type { StartJobRequest } from './action-types.js';
24
+ import { badRequest, forbidden, internalError, notFound } from 'takos-common/middleware/hono';
25
+ import { ErrorCodes } from 'takos-common/errors';
26
+
27
+ const app = new Hono();
28
+
29
+ // ---------------------------------------------------------------------------
30
+ // Job lifecycle
31
+ // ---------------------------------------------------------------------------
32
+
33
+ app.post('/actions/jobs/:jobId/start', async (c) => {
34
+ const jobId = c.req.param('jobId');
35
+ const body = await c.req.json() as StartJobRequest | undefined;
36
+
37
+ try {
38
+ if (body?.space_id !== undefined) {
39
+ if (typeof body.space_id !== 'string' || body.space_id.length === 0) {
40
+ return badRequest(c, 'space_id must be a non-empty string when provided');
41
+ }
42
+ }
43
+
44
+ const scopedSpaceId = getScopedSpaceId(c);
45
+ if (scopedSpaceId && !body?.space_id) {
46
+ return badRequest(c, 'space_id is required for space-scoped token');
47
+ }
48
+ if (hasSpaceScopeMismatch(c, body?.space_id)) {
49
+ return forbidden(c, SPACE_SCOPE_MISMATCH_ERROR);
50
+ }
51
+
52
+ if (!body || !body.repoId || !body.ref || !body.sha) {
53
+ return badRequest(c, 'Missing required fields: repoId, ref, sha');
54
+ }
55
+
56
+ if (!body.steps || !Array.isArray(body.steps) || body.steps.length === 0) {
57
+ return badRequest(c, 'Missing or invalid steps array');
58
+ }
59
+ if (body.steps.length > SANDBOX_LIMITS.maxStepsPerJob) {
60
+ return badRequest(c, `Steps exceed per-job limit (max ${SANDBOX_LIMITS.maxStepsPerJob})`);
61
+ }
62
+
63
+ if (jobManager.jobs.has(jobId)) {
64
+ return c.json({ error: { code: ErrorCodes.CONFLICT, message: 'Job already exists' } }, 409);
65
+ }
66
+
67
+ const jobSpaceId = body.space_id ?? scopedSpaceId ?? '__unspecified_workspace__';
68
+
69
+ const runningJobs = jobManager.countRunningJobsForSpace(jobSpaceId);
70
+ if (runningJobs >= SANDBOX_LIMITS.maxConcurrentJobs) {
71
+ return c.json({ error: { code: ErrorCodes.RATE_LIMITED, message: `Concurrent job limit reached (max ${SANDBOX_LIMITS.maxConcurrentJobs})` } }, 429);
72
+ }
73
+
74
+ const workspacePath = path.join(
75
+ os.tmpdir(),
76
+ `takos-actions-${jobId.slice(0, 8)}-${Date.now()}`
77
+ );
78
+ await fs.mkdir(workspacePath, { recursive: true });
79
+
80
+ const logs: string[] = [];
81
+ pushLog(logs, `Starting job: ${body.jobName || jobId}`);
82
+ pushLog(logs, `Repository: ${body.repoId}`);
83
+ pushLog(logs, `Ref: ${body.ref} (${body.sha})`);
84
+
85
+ const baseEnv = createSandboxEnv({
86
+ ...body.env,
87
+ GITHUB_ACTIONS: 'true',
88
+ CI: 'true',
89
+ GITHUB_WORKSPACE: workspacePath,
90
+ GITHUB_REPOSITORY: body.repoId,
91
+ GITHUB_REF: body.ref,
92
+ GITHUB_SHA: body.sha,
93
+ GITHUB_JOB: body.jobName || jobId,
94
+ GITHUB_RUN_ID: jobId,
95
+ GITHUB_WORKFLOW: body.workflowPath,
96
+ }, SANDBOX_LIMITS.maxEnvValueLength);
97
+
98
+ const secretsSanitizer = createSecretsSanitizer(
99
+ body.secrets || {},
100
+ collectSensitiveEnvValues(body.env)
101
+ );
102
+
103
+ const job: ActiveJob = {
104
+ id: jobId,
105
+ spaceId: jobSpaceId,
106
+ repoId: body.repoId,
107
+ ref: body.ref,
108
+ sha: body.sha,
109
+ workflowPath: body.workflowPath,
110
+ jobName: body.jobName || jobId,
111
+ workspacePath,
112
+ status: 'running',
113
+ steps: body.steps,
114
+ env: baseEnv,
115
+ secrets: body.secrets || {},
116
+ secretsSanitizer,
117
+ logs,
118
+ currentStep: 0,
119
+ startedAt: Date.now(),
120
+ outputs: {},
121
+ };
122
+
123
+ jobManager.jobs.set(jobId, job);
124
+
125
+ pushLog(logs, 'Job directory created successfully');
126
+ pushLog(logs, `Working path: ${workspacePath}`);
127
+
128
+ return c.json({
129
+ jobId,
130
+ status: 'running',
131
+ workspacePath,
132
+ message: 'Job started successfully',
133
+ });
134
+ } catch (err) {
135
+ c.get('log')?.error('Error starting job', { jobId, error: err });
136
+ return internalError(c, 'Failed to start job');
137
+ }
138
+ });
139
+
140
+ app.post('/actions/jobs/:jobId/complete', async (c) => {
141
+ const jobId = c.req.param('jobId');
142
+ const { conclusion, uploadLogs } = await c.req.json() as {
143
+ conclusion?: 'success' | 'failure' | 'cancelled';
144
+ uploadLogs?: boolean;
145
+ };
146
+
147
+ try {
148
+ const job = jobManager.jobs.get(jobId);
149
+ if (!job) return notFound(c, 'Job not found');
150
+
151
+ job.status = conclusion === 'success' ? 'completed' : 'failed';
152
+ job.conclusion = conclusion || 'success';
153
+ job.completedAt = Date.now();
154
+
155
+ pushLog(job.logs, `\n=== Job ${job.conclusion} ===`, job.secretsSanitizer);
156
+ pushLog(job.logs, `Duration: ${(job.completedAt - job.startedAt) / 1000}s`, job.secretsSanitizer);
157
+
158
+ let logsUrl: string | undefined;
159
+ if (uploadLogs && isR2Configured()) {
160
+ try {
161
+ const logsKey = `actions/jobs/${jobId}/logs.txt`;
162
+ const sanitizedLogs = job.secretsSanitizer.sanitizeLogs(job.logs);
163
+ await s3Client.send(
164
+ new PutObjectCommand({
165
+ Bucket: R2_BUCKET,
166
+ Key: logsKey,
167
+ Body: sanitizedLogs.join('\n'),
168
+ ContentType: 'text/plain',
169
+ Metadata: {
170
+ 'job-id': jobId,
171
+ 'conclusion': job.conclusion,
172
+ 'completed-at': new Date(job.completedAt).toISOString(),
173
+ },
174
+ })
175
+ );
176
+ logsUrl = logsKey;
177
+ pushLog(job.logs, `Logs uploaded to R2: ${logsKey}`, job.secretsSanitizer);
178
+ } catch (uploadErr) {
179
+ c.get('log')?.error('Failed to upload logs', { jobId, error: uploadErr });
180
+ }
181
+ }
182
+
183
+ try {
184
+ await fs.rm(job.workspacePath, { recursive: true, force: true });
185
+ pushLog(job.logs, 'Job directory cleaned up', job.secretsSanitizer);
186
+ } catch {
187
+ c.get('log')?.warn('Failed to cleanup job directory', { jobId });
188
+ }
189
+
190
+ const response = {
191
+ jobId,
192
+ status: job.status,
193
+ conclusion: job.conclusion,
194
+ duration: (job.completedAt - job.startedAt) / 1000,
195
+ outputs: job.outputs,
196
+ logsUrl,
197
+ };
198
+
199
+ jobManager.scheduleJobCleanup(jobId);
200
+
201
+ return c.json(response);
202
+ } catch (err) {
203
+ c.get('log')?.error('Error completing job', { jobId, error: err });
204
+ return internalError(c, 'Failed to complete job');
205
+ }
206
+ });
207
+
208
+ // ---------------------------------------------------------------------------
209
+ // Job cancellation
210
+ // ---------------------------------------------------------------------------
211
+
212
+ app.delete('/actions/jobs/:jobId', async (c) => {
213
+ const jobId = c.req.param('jobId');
214
+
215
+ try {
216
+ const job = jobManager.jobs.get(jobId);
217
+ if (!job) return notFound(c, 'Job not found');
218
+
219
+ job.status = 'failed';
220
+ job.conclusion = 'cancelled';
221
+ job.completedAt = Date.now();
222
+
223
+ pushLog(job.logs, '\n=== Job cancelled ===', job.secretsSanitizer);
224
+
225
+ job.secretsSanitizer.clear();
226
+
227
+ await removeJobDirSafe(job.workspacePath, jobId, 'cancelled job');
228
+
229
+ jobManager.jobs.delete(jobId);
230
+
231
+ return c.json({
232
+ jobId,
233
+ status: 'cancelled',
234
+ message: 'Job cancelled and cleaned up',
235
+ });
236
+ } catch (err) {
237
+ c.get('log')?.error('Error cancelling job', { jobId, error: err });
238
+ return internalError(c, 'Failed to cancel job');
239
+ }
240
+ });
241
+
242
+ export default app;
@@ -0,0 +1,52 @@
1
+ import { Hono } from 'hono';
2
+ import {
3
+ jobManager,
4
+ sanitizeOutputs,
5
+ } from '../../runtime/actions/job-manager.js';
6
+ import { notFound } from 'takos-common/middleware/hono';
7
+
8
+ const app = new Hono();
9
+
10
+ // ---------------------------------------------------------------------------
11
+ // Job status & logs
12
+ // ---------------------------------------------------------------------------
13
+
14
+ app.get('/actions/jobs/:jobId/status', (c) => {
15
+ const jobId = c.req.param('jobId');
16
+
17
+ const job = jobManager.jobs.get(jobId);
18
+ if (!job) return notFound(c, 'Job not found');
19
+
20
+ return c.json({
21
+ jobId,
22
+ status: job.status,
23
+ conclusion: job.conclusion,
24
+ currentStep: job.currentStep,
25
+ totalSteps: job.steps.length,
26
+ startedAt: job.startedAt,
27
+ completedAt: job.completedAt,
28
+ outputs: sanitizeOutputs(job.outputs, job.secretsSanitizer),
29
+ });
30
+ });
31
+
32
+ app.get('/actions/jobs/:jobId/logs', (c) => {
33
+ const jobId = c.req.param('jobId');
34
+ const offset = c.req.query('offset');
35
+
36
+ const job = jobManager.jobs.get(jobId);
37
+ if (!job) return notFound(c, 'Job not found');
38
+
39
+ const rawOffset = offset ? parseInt(offset, 10) : 0;
40
+ const startOffset = Number.isFinite(rawOffset) ? rawOffset : 0;
41
+ const rawLogs = job.logs.slice(startOffset);
42
+ const logs = job.secretsSanitizer.sanitizeLogs(rawLogs);
43
+
44
+ return c.json({
45
+ logs,
46
+ offset: startOffset,
47
+ total: job.logs.length,
48
+ hasMore: startOffset + logs.length < job.logs.length,
49
+ });
50
+ });
51
+
52
+ export default app;
@@ -0,0 +1,105 @@
1
+ import { Hono } from 'hono';
2
+ import type { ContentfulStatusCode } from 'hono/utils/http-status';
3
+ import { PROXY_BASE_URL } from '../../shared/config.js';
4
+ import { isValidSessionId } from '../../runtime/validation.js';
5
+ import { sessionStore } from '../sessions/storage.js';
6
+ import { badRequest, internalError, forbidden } from 'takos-common/middleware/hono';
7
+
8
+ const app = new Hono();
9
+
10
+ const ALLOWED_PATHS = [
11
+ /^\/api\/repos\/[^/]+\/import$/,
12
+ /^\/api\/repos\/[^/]+\/export$/,
13
+ /^\/api\/repos\/[^/]+\/status$/,
14
+ /^\/api\/repos\/[^/]+\/log$/,
15
+ /^\/api\/repos\/[^/]+\/commit$/,
16
+ ];
17
+
18
+ function getProxyPathAndQuery(c: import('hono').Context): { apiPath: string; apiQuery: string } {
19
+ // In Hono, c.req.path gives us the path matched by the route.
20
+ // c.req.url gives the full URL. We need to extract the proxy target.
21
+ const url = new URL(c.req.url);
22
+ const fullPath = url.pathname;
23
+ const rawProxyTarget = fullPath.startsWith('/cli-proxy')
24
+ ? fullPath.slice('/cli-proxy'.length)
25
+ : fullPath;
26
+ const [apiPath, ...queryParts] = rawProxyTarget.split('?');
27
+ return {
28
+ apiPath: apiPath ?? '',
29
+ apiQuery: queryParts.join('?') || url.search.slice(1),
30
+ };
31
+ }
32
+
33
+ app.all('/cli-proxy/*', async (c) => {
34
+ try {
35
+ const sessionId = c.req.header('X-Takos-Session-Id');
36
+ if (!sessionId) {
37
+ return badRequest(c, 'Missing X-Takos-Session-Id header');
38
+ }
39
+ if (!isValidSessionId(sessionId)) {
40
+ return badRequest(c, 'Invalid X-Takos-Session-Id');
41
+ }
42
+
43
+ const session = sessionStore.getSession(sessionId);
44
+ if (!session) {
45
+ return forbidden(c, 'Session not found');
46
+ }
47
+ const spaceId = c.req.header('X-Takos-Space-Id');
48
+ if (spaceId && session.spaceId !== spaceId) {
49
+ return forbidden(c, 'Session does not belong to workspace');
50
+ }
51
+ session.lastAccessedAt = Date.now();
52
+
53
+ const { apiPath, apiQuery } = getProxyPathAndQuery(c);
54
+ if (!apiPath) {
55
+ return badRequest(c, 'API path required');
56
+ }
57
+ if (apiPath.includes('..') || apiPath.includes('//')) {
58
+ return badRequest(c, 'Invalid API path');
59
+ }
60
+
61
+ if (!ALLOWED_PATHS.some(pattern => pattern.test(apiPath))) {
62
+ return forbidden(c, `Path not allowed: ${apiPath}`);
63
+ }
64
+
65
+ const proxyToken = session.proxyToken;
66
+ if (!PROXY_BASE_URL || !proxyToken) {
67
+ return internalError(c, 'PROXY_BASE_URL or proxy token not configured');
68
+ }
69
+
70
+ const headers: Record<string, string> = {
71
+ 'Content-Type': c.req.header('content-type') || 'application/json',
72
+ 'X-Takos-Session-Id': sessionId,
73
+ 'Authorization': `Bearer ${proxyToken}`,
74
+ };
75
+
76
+ const fetchOptions: RequestInit = {
77
+ method: c.req.method,
78
+ headers,
79
+ };
80
+
81
+ if (c.req.method !== 'GET' && c.req.method !== 'HEAD') {
82
+ const body = await c.req.json();
83
+ fetchOptions.body = JSON.stringify(body);
84
+ }
85
+
86
+ const baseUrl = PROXY_BASE_URL.endsWith('/') ? PROXY_BASE_URL.slice(0, -1) : PROXY_BASE_URL;
87
+ const targetUrl = new URL(`/forward/cli-proxy${apiPath}`, baseUrl);
88
+ if (apiQuery) {
89
+ targetUrl.search = `?${apiQuery}`;
90
+ }
91
+ const response = await fetch(targetUrl.toString(), fetchOptions);
92
+ const text = await response.text();
93
+ try {
94
+ const data = JSON.parse(text);
95
+ return c.json(data, response.status as ContentfulStatusCode);
96
+ } catch {
97
+ return c.text(text, response.status as ContentfulStatusCode);
98
+ }
99
+ } catch (err) {
100
+ c.get('log')?.error('CLI proxy error', { error: err });
101
+ return internalError(c, 'Failed to proxy request');
102
+ }
103
+ });
104
+
105
+ export default app;