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/src/app.ts ADDED
@@ -0,0 +1,245 @@
1
+ import { Hono } from 'hono';
2
+ import { serve } from '@hono/node-server';
3
+ import { randomUUID } from 'node:crypto';
4
+ import {
5
+ PORT,
6
+ JWT_PUBLIC_KEY,
7
+ RATE_LIMIT_WINDOW_MS,
8
+ RATE_LIMIT_EXEC_MAX,
9
+ RATE_LIMIT_SESSION_MAX,
10
+ RATE_LIMIT_SNAPSHOT_MAX,
11
+ RATE_LIMIT_ACTIONS_MAX,
12
+ RATE_LIMIT_GIT_MAX,
13
+ RATE_LIMIT_REPOS_MAX,
14
+ RATE_LIMIT_CLI_PROXY_MAX,
15
+ } from './shared/config.js';
16
+ import {
17
+ createServiceTokenMiddleware,
18
+ getServiceTokenFromHeader,
19
+ createErrorHandler,
20
+ notFoundHandler,
21
+ forbidden,
22
+ } from 'takos-common/middleware/hono';
23
+ import { createRateLimiter } from './middleware/rate-limit.js';
24
+ import execRoutes from './routes/runtime/exec.js';
25
+ import toolsRoutes from './routes/runtime/tools.js';
26
+ import sessionExecutionRoutes from './routes/sessions/execution.js';
27
+ import sessionFilesRoutes from './routes/sessions/files.js';
28
+ import sessionSnapshotRoutes from './routes/sessions/snapshot.js';
29
+ import sessionSessionsRoutes from './routes/sessions/session-routes.js';
30
+ import repoReadRoutes from './routes/repos/read.js';
31
+ import repoWriteRoutes from './routes/repos/write.js';
32
+ import {
33
+ enforceSpaceScopeMiddleware,
34
+ getSpaceIdFromBody,
35
+ getSpaceIdFromPath,
36
+ } from './middleware/space-scope.js';
37
+ import gitInitRoutes from './routes/git/init.js';
38
+ import gitHttpRoutes from './routes/git/http.js';
39
+ import actionsRoutes from './routes/actions/index.js';
40
+ import { jobManager } from './runtime/actions/job-manager.js';
41
+ import cliProxyRoutes from './routes/cli/proxy.js';
42
+ import { isR2Configured } from './storage/r2.js';
43
+ import { sessionStore } from './routes/sessions/storage.js';
44
+ import { createLogger } from 'takos-common/logger';
45
+
46
+ export type RuntimeServiceOptions = {
47
+ port?: number;
48
+ serviceName?: string;
49
+ isProduction?: boolean;
50
+ isContainerEnvironment?: boolean;
51
+ };
52
+
53
+ function isLoopbackAddress(addr: string): boolean {
54
+ return addr === '127.0.0.1' || addr === '::1' || addr === '::ffff:127.0.0.1';
55
+ }
56
+
57
+ function isLocalCliProxyBypassRequest(c: import('hono').Context): boolean {
58
+ if (!c.req.path.startsWith('/cli-proxy/')) {
59
+ return false;
60
+ }
61
+ if (getServiceTokenFromHeader(c)) {
62
+ return false;
63
+ }
64
+
65
+ const sessionId = c.req.header('X-Takos-Session-Id');
66
+ const addr =
67
+ c.req.header('x-forwarded-for')?.split(',')[0]?.trim()
68
+ || c.req.header('x-real-ip')
69
+ || '';
70
+ return Boolean(sessionId) && isLoopbackAddress(addr);
71
+ }
72
+
73
+ export function createRuntimeServiceApp(options: RuntimeServiceOptions = {}): Hono {
74
+ const requireServiceToken = createServiceTokenMiddleware({
75
+ jwtPublicKey: JWT_PUBLIC_KEY,
76
+ expectedIssuer: 'takos-control',
77
+ expectedAudience: 'takos-runtime',
78
+ skipPaths: ['/health'],
79
+ clockToleranceSeconds: 30,
80
+ });
81
+
82
+ const isProduction = options.isProduction ?? process.env.NODE_ENV === 'production';
83
+ const isContainerEnvironment = options.isContainerEnvironment ?? !!process.env.CF_CONTAINER;
84
+ const logger = createLogger({ service: options.serviceName ?? 'takos-runtime' });
85
+ const app = new Hono();
86
+
87
+ app.use(async (c, next) => {
88
+ const id = c.req.header('x-request-id') || randomUUID();
89
+ const log = logger.child({ requestId: id });
90
+ c.set('requestId', id);
91
+ c.set('log', log);
92
+ c.header('x-request-id', id);
93
+ const start = Date.now();
94
+ log.info('Request', { method: c.req.method, path: c.req.path });
95
+ await next();
96
+ log.info('Response', {
97
+ method: c.req.method,
98
+ path: c.req.path,
99
+ status: c.res.status,
100
+ duration: Date.now() - start,
101
+ });
102
+ });
103
+
104
+ app.use(async (c, next): Promise<Response | void> => {
105
+ if (isProduction && !isContainerEnvironment && c.req.header('X-Forwarded-Proto') !== 'https') {
106
+ return forbidden(c, 'HTTPS required');
107
+ }
108
+ await next();
109
+ });
110
+
111
+ app.use(async (c, next) => {
112
+ c.header('X-Content-Type-Options', 'nosniff');
113
+ if (isProduction) {
114
+ c.header('Strict-Transport-Security', 'max-age=31536000; includeSubDomains; preload');
115
+ }
116
+ await next();
117
+ });
118
+
119
+ app.get('/ping', (c) => c.text('pong', 200));
120
+ app.get('/health', (c) => c.json({ ok: true }, 200));
121
+
122
+ app.use('/cli-proxy/*', async (c, next) => {
123
+ if (getServiceTokenFromHeader(c)) {
124
+ await next();
125
+ return;
126
+ }
127
+
128
+ if (isLocalCliProxyBypassRequest(c)) {
129
+ await next();
130
+ return;
131
+ }
132
+
133
+ return forbidden(c, 'Authorization header required');
134
+ });
135
+
136
+ app.use(async (c, next) => {
137
+ if (isLocalCliProxyBypassRequest(c)) {
138
+ await next();
139
+ return;
140
+ }
141
+ return requireServiceToken(c, next);
142
+ });
143
+
144
+ app.use(async (c, next) => {
145
+ const ct = c.req.header('content-type') || '';
146
+ if (c.req.method !== 'GET' && c.req.method !== 'HEAD' && ct.includes('application/json')) {
147
+ try {
148
+ const body = await c.req.json();
149
+ c.set('parsedBody', body);
150
+ } catch {
151
+ // Ignore malformed JSON; downstream handlers surface validation errors.
152
+ }
153
+ }
154
+ await next();
155
+ });
156
+
157
+ const execRateLimiter = createRateLimiter({ maxRequests: RATE_LIMIT_EXEC_MAX, windowMs: RATE_LIMIT_WINDOW_MS });
158
+ const sessionRateLimiter = createRateLimiter({ maxRequests: RATE_LIMIT_SESSION_MAX, windowMs: RATE_LIMIT_WINDOW_MS });
159
+ const snapshotRateLimiter = createRateLimiter({ maxRequests: RATE_LIMIT_SNAPSHOT_MAX, windowMs: RATE_LIMIT_WINDOW_MS });
160
+ const actionsRateLimiter = createRateLimiter({ maxRequests: RATE_LIMIT_ACTIONS_MAX, windowMs: RATE_LIMIT_WINDOW_MS });
161
+ const gitRateLimiter = createRateLimiter({ maxRequests: RATE_LIMIT_GIT_MAX, windowMs: RATE_LIMIT_WINDOW_MS });
162
+ const reposRateLimiter = createRateLimiter({ maxRequests: RATE_LIMIT_REPOS_MAX, windowMs: RATE_LIMIT_WINDOW_MS });
163
+ const cliProxyRateLimiter = createRateLimiter({ maxRequests: RATE_LIMIT_CLI_PROXY_MAX, windowMs: RATE_LIMIT_WINDOW_MS });
164
+
165
+ app.use('/exec/*', execRateLimiter);
166
+ app.use('/session/exec/*', execRateLimiter);
167
+ app.use('/session/snapshot/*', snapshotRateLimiter);
168
+ app.use('/session/*', sessionRateLimiter);
169
+ app.use('/sessions/*', sessionRateLimiter);
170
+ app.use('/actions/*', actionsRateLimiter);
171
+ app.use('/git/*', gitRateLimiter);
172
+ app.use('/repos/*', reposRateLimiter);
173
+ app.use('/cli-proxy/*', cliProxyRateLimiter);
174
+
175
+ const sessionSpaceScope = enforceSpaceScopeMiddleware((c) => [
176
+ getSpaceIdFromBody(c, 'space_id'),
177
+ ]);
178
+ const repoSpaceScope = enforceSpaceScopeMiddleware((c) => [
179
+ getSpaceIdFromBody(c, 'spaceId'),
180
+ getSpaceIdFromPath(c),
181
+ ]);
182
+ app.use('/session/*', sessionSpaceScope);
183
+ app.use('/sessions/*', sessionSpaceScope);
184
+ app.use('/repos/*', repoSpaceScope);
185
+
186
+ app.route('/', cliProxyRoutes);
187
+ app.route('/', execRoutes);
188
+ app.route('/', toolsRoutes);
189
+ app.route('/', sessionExecutionRoutes);
190
+ app.route('/', sessionFilesRoutes);
191
+ app.route('/', sessionSnapshotRoutes);
192
+ app.route('/', sessionSessionsRoutes);
193
+ app.route('/', repoReadRoutes);
194
+ app.route('/', repoWriteRoutes);
195
+ app.route('/', gitInitRoutes);
196
+ app.route('/', gitHttpRoutes);
197
+ app.route('/', actionsRoutes);
198
+
199
+ app.notFound(notFoundHandler);
200
+ app.onError(createErrorHandler({ includeStack: !isProduction }));
201
+ return app;
202
+ }
203
+
204
+ export function startRuntimeService(options: RuntimeServiceOptions = {}) {
205
+ const logger = createLogger({ service: options.serviceName ?? 'takos-runtime' });
206
+ const port = options.port ?? PORT;
207
+ const app = createRuntimeServiceApp(options);
208
+
209
+ sessionStore.startCleanup();
210
+ jobManager.startCleanup();
211
+
212
+ const server = serve({ fetch: app.fetch, port }, () => {
213
+ logger.info(`Takos runtime listening on port ${port}`);
214
+ logger.info(`R2 configured: ${isR2Configured()}`);
215
+ });
216
+
217
+ let shuttingDown = false;
218
+ function shutdown(signal: NodeJS.Signals): void {
219
+ if (shuttingDown) return;
220
+ shuttingDown = true;
221
+
222
+ logger.info('Shutdown signal received', { signal });
223
+ sessionStore.stopCleanup();
224
+ jobManager.stopCleanup();
225
+
226
+ server.close((err) => {
227
+ if (err) {
228
+ logger.error('Error while closing HTTP server', { signal, error: err });
229
+ process.exit(1);
230
+ return;
231
+ }
232
+ process.exit(0);
233
+ });
234
+
235
+ setTimeout(() => {
236
+ logger.error('Forced shutdown timeout reached', { signal });
237
+ process.exit(1);
238
+ }, 10_000).unref();
239
+ }
240
+
241
+ process.once('SIGTERM', () => shutdown('SIGTERM'));
242
+ process.once('SIGINT', () => shutdown('SIGINT'));
243
+
244
+ return { app, server };
245
+ }
package/src/index.ts ADDED
@@ -0,0 +1 @@
1
+ export * from './app.js';
@@ -0,0 +1,91 @@
1
+ import type { Context, Next } from 'hono';
2
+
3
+ interface RateLimitEntry {
4
+ count: number;
5
+ resetAt: number;
6
+ }
7
+
8
+ interface RateLimitOptions {
9
+ maxRequests: number;
10
+ windowMs: number;
11
+ keyFn?: (c: Context) => string;
12
+ maxKeys?: number;
13
+ }
14
+
15
+ export function createRateLimiter(options: RateLimitOptions) {
16
+ const { maxRequests, windowMs, maxKeys = 10000 } = options;
17
+ const store = new Map<string, RateLimitEntry>();
18
+
19
+ const cleanupInterval = Math.min(windowMs, 60_000);
20
+ const cleanupTimer = setInterval(() => {
21
+ const now = Date.now();
22
+ for (const [key, entry] of store.entries()) {
23
+ if (now >= entry.resetAt) {
24
+ store.delete(key);
25
+ }
26
+ }
27
+ }, cleanupInterval);
28
+ if (cleanupTimer.unref) {
29
+ cleanupTimer.unref();
30
+ }
31
+
32
+ const defaultKeyFn = (c: Context): string => {
33
+ // In Hono with @hono/node-server, use the client's IP from the
34
+ // incoming connection info or forwarded header.
35
+ const ip =
36
+ c.req.header('x-forwarded-for')?.split(',')[0]?.trim() ||
37
+ c.req.header('x-real-ip') ||
38
+ 'unknown';
39
+ const spaceId = c.req.header('X-Takos-Space-Id') || '';
40
+ return spaceId ? `${ip}:${spaceId}` : ip;
41
+ };
42
+
43
+ const keyFn = options.keyFn || defaultKeyFn;
44
+
45
+ return async (c: Context, next: Next): Promise<Response | void> => {
46
+ const key = keyFn(c);
47
+ const now = Date.now();
48
+ let entry = store.get(key);
49
+
50
+ if (!entry || now >= entry.resetAt) {
51
+ if (!entry && store.size >= maxKeys) {
52
+ let evicted = false;
53
+ for (const [k, e] of store.entries()) {
54
+ if (now >= e.resetAt) {
55
+ store.delete(k);
56
+ evicted = true;
57
+ break;
58
+ }
59
+ }
60
+ if (!evicted && store.size >= maxKeys) {
61
+ const retryAfter = Math.ceil(windowMs / 1000);
62
+ c.header('Retry-After', String(retryAfter));
63
+ return c.json({
64
+ error: 'Rate limiter capacity reached. Please try again later.',
65
+ retry_after_seconds: retryAfter,
66
+ }, 429);
67
+ }
68
+ }
69
+ entry = { count: 0, resetAt: now + windowMs };
70
+ store.set(key, entry);
71
+ }
72
+
73
+ entry.count++;
74
+
75
+ const remaining = Math.max(0, maxRequests - entry.count);
76
+ c.header('X-RateLimit-Limit', String(maxRequests));
77
+ c.header('X-RateLimit-Remaining', String(remaining));
78
+ c.header('X-RateLimit-Reset', String(Math.ceil(entry.resetAt / 1000)));
79
+
80
+ if (entry.count > maxRequests) {
81
+ const retryAfter = Math.ceil((entry.resetAt - now) / 1000);
82
+ c.header('Retry-After', String(retryAfter));
83
+ return c.json({
84
+ error: 'Too many requests. Please try again later.',
85
+ retry_after_seconds: retryAfter,
86
+ }, 429);
87
+ }
88
+
89
+ await next();
90
+ };
91
+ }
@@ -0,0 +1,95 @@
1
+ import type { Context, Next } from 'hono';
2
+ import { forbidden } from 'takos-common/middleware/hono';
3
+
4
+ export const SPACE_SCOPE_MISMATCH_ERROR = 'Token workspace scope does not match requested workspace';
5
+
6
+ export function getSpaceIdFromPath(c: Context): string | null {
7
+ const pathParts = c.req.path.split('/').filter(Boolean);
8
+ if (pathParts[0] !== 'repos' || pathParts.length < 3) {
9
+ return null;
10
+ }
11
+ const spaceId = pathParts[1];
12
+ if (typeof spaceId !== 'string' || spaceId.length === 0) {
13
+ return null;
14
+ }
15
+ return spaceId;
16
+ }
17
+
18
+ function isProvidedSpaceId(spaceId: unknown): boolean {
19
+ return spaceId !== undefined && spaceId !== null && spaceId !== '';
20
+ }
21
+
22
+ function isNonEmptyString(value: unknown): value is string {
23
+ return typeof value === 'string' && value.length > 0;
24
+ }
25
+
26
+ export function getSpaceIdFromBody(c: Context, field: 'spaceId' | 'space_id'): string | null {
27
+ const body = c.get('parsedBody') as Record<string, unknown> | undefined;
28
+ const spaceId = body?.[field];
29
+ return isNonEmptyString(spaceId) ? spaceId : null;
30
+ }
31
+
32
+ export function collectRequestedSpaceIds(spaceIds: readonly unknown[]): string[] {
33
+ return [...new Set(spaceIds.filter(isNonEmptyString))];
34
+ }
35
+
36
+ export function getScopedSpaceId(c: Context): string | undefined {
37
+ const payload = c.get('serviceToken');
38
+ if (!payload) {
39
+ return undefined;
40
+ }
41
+ return typeof payload.scope_space_id === 'string'
42
+ ? payload.scope_space_id
43
+ : undefined;
44
+ }
45
+
46
+ export function hasSpaceScopeMismatch(c: Context, spaceId: unknown): boolean {
47
+ if (!isProvidedSpaceId(spaceId)) {
48
+ return false;
49
+ }
50
+ const scopedSpaceId = getScopedSpaceId(c);
51
+ return typeof scopedSpaceId === 'string' && scopedSpaceId !== spaceId;
52
+ }
53
+
54
+ export function hasAnySpaceScopeMismatch(c: Context, spaceIds: readonly unknown[]): boolean {
55
+ for (const spaceId of spaceIds) {
56
+ if (hasSpaceScopeMismatch(c, spaceId)) {
57
+ return true;
58
+ }
59
+ }
60
+ return false;
61
+ }
62
+
63
+ /**
64
+ * Creates a Hono middleware that enforces space scope by extracting
65
+ * space IDs from the context using the provided extractor function.
66
+ *
67
+ * If a single non-empty space ID is found and it mismatches the token scope,
68
+ * the request is rejected with 403. If multiple conflicting space IDs are
69
+ * found, the request is also rejected.
70
+ *
71
+ * Note: expects the request body to already be parsed and stored in
72
+ * c.get('parsedBody') by an upstream middleware (see index.ts).
73
+ */
74
+ export function enforceSpaceScopeMiddleware(
75
+ extractSpaceIds: (c: Context) => readonly unknown[]
76
+ ): (c: Context, next: Next) => Promise<Response | void> {
77
+ return async (c: Context, next: Next): Promise<Response | void> => {
78
+ const spaceIds = collectRequestedSpaceIds(extractSpaceIds(c));
79
+
80
+ if (spaceIds.length === 0) {
81
+ await next();
82
+ return;
83
+ }
84
+
85
+ if (new Set(spaceIds).size > 1) {
86
+ return forbidden(c, 'Conflicting workspace identifiers in request');
87
+ }
88
+
89
+ if (hasAnySpaceScopeMismatch(c, spaceIds)) {
90
+ return forbidden(c, SPACE_SCOPE_MISMATCH_ERROR);
91
+ }
92
+
93
+ await next();
94
+ };
95
+ }
@@ -0,0 +1,20 @@
1
+ export interface StartJobRequest {
2
+ space_id?: string;
3
+ repoId: string;
4
+ ref: string;
5
+ sha: string;
6
+ workflowPath: string;
7
+ jobName: string;
8
+ steps: Array<{
9
+ name?: string;
10
+ run?: string;
11
+ uses?: string;
12
+ with?: Record<string, unknown>;
13
+ env?: Record<string, string>;
14
+ if?: string;
15
+ 'continue-on-error'?: boolean;
16
+ 'timeout-minutes'?: number;
17
+ }>;
18
+ env?: Record<string, string>;
19
+ secrets?: Record<string, string>;
20
+ }
@@ -0,0 +1,229 @@
1
+ import { Hono } from 'hono';
2
+ import * as fs from 'fs/promises';
3
+ import { StepExecutor, type ExecutorStepResult } from '../../runtime/actions/executor.js';
4
+ import { SANDBOX_LIMITS } from '../../shared/config.js';
5
+ import { shouldBlockForSecretExposure, mightExposeSecrets } from '../../runtime/actions/secrets.js';
6
+ import { pushLog } from '../../runtime/logging.js';
7
+ import { cloneAndCheckout } from '../../runtime/git.js';
8
+ import { resolvePathWithin } from '../../runtime/paths.js';
9
+ import { GIT_ENDPOINT_URL } from '../../shared/config.js';
10
+ import { collectSensitiveEnvValues } from '../../runtime/actions/secrets.js';
11
+
12
+ interface ExecuteStepRequest {
13
+ run?: string;
14
+ uses?: string;
15
+ with?: Record<string, unknown>;
16
+ env?: Record<string, string>;
17
+ name?: string;
18
+ shell?: string;
19
+ 'working-directory'?: string;
20
+ 'continue-on-error'?: boolean;
21
+ 'timeout-minutes'?: number;
22
+ }
23
+ import {
24
+ jobManager,
25
+ sanitizeOutputs,
26
+ } from '../../runtime/actions/job-manager.js';
27
+ import { internalError } from 'takos-common/middleware/hono';
28
+
29
+ const app = new Hono();
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // checkout
33
+ // ---------------------------------------------------------------------------
34
+
35
+ app.post('/actions/jobs/:jobId/checkout', async (c) => {
36
+ const jobId = c.req.param('jobId');
37
+ const { repoUrl, ref, path: checkoutPath } = await c.req.json() as {
38
+ repoUrl?: string;
39
+ ref?: string;
40
+ path?: string;
41
+ };
42
+
43
+ try {
44
+ const job = jobManager.jobs.get(jobId);
45
+ if (!job || job.status !== 'running') return c.json({ error: 'Job not found or not running' }, 404);
46
+
47
+ const targetPath = checkoutPath
48
+ ? resolvePathWithin(job.workspacePath, checkoutPath, 'checkout path', true)
49
+ : job.workspacePath;
50
+
51
+ await fs.mkdir(targetPath, { recursive: true });
52
+
53
+ pushLog(job.logs, `Checking out repository...`);
54
+
55
+ // Validate repoUrl to prevent SSRF — only allow the configured git endpoint or default
56
+ if (repoUrl && !repoUrl.startsWith(GIT_ENDPOINT_URL)) {
57
+ return c.json({ error: 'Invalid repoUrl: must use the configured git endpoint' }, 400);
58
+ }
59
+ const gitUrl = repoUrl || `${GIT_ENDPOINT_URL}/${job.repoId}.git`;
60
+ const gitRef = ref || job.ref;
61
+ const cloneResult = await cloneAndCheckout({
62
+ repoUrl: gitUrl,
63
+ targetDir: targetPath,
64
+ ref: gitRef,
65
+ shallow: true,
66
+ env: job.env,
67
+ });
68
+
69
+ if (!cloneResult.success) {
70
+ pushLog(job.logs, `Checkout failed: ${cloneResult.output}`);
71
+ return c.json({
72
+ error: 'Checkout failed',
73
+ output: cloneResult.output,
74
+ }, 500);
75
+ }
76
+
77
+ pushLog(job.logs, 'Checkout completed successfully');
78
+
79
+ return c.json({
80
+ success: true,
81
+ path: targetPath,
82
+ });
83
+ } catch (err) {
84
+ c.get('log')?.error('Error during checkout', { jobId, error: err });
85
+ return internalError(c, 'Checkout failed');
86
+ }
87
+ });
88
+
89
+ // ---------------------------------------------------------------------------
90
+ // step execution
91
+ // ---------------------------------------------------------------------------
92
+
93
+ app.post('/actions/jobs/:jobId/step/:stepNumber', async (c) => {
94
+ const jobId = c.req.param('jobId');
95
+ const stepNumber = c.req.param('stepNumber');
96
+ const body = (await c.req.json().catch(() => ({}))) as ExecuteStepRequest;
97
+
98
+ try {
99
+ const job = jobManager.jobs.get(jobId);
100
+ if (!job || job.status !== 'running') return c.json({ error: 'Job not found or not running' }, 404);
101
+
102
+ const stepNum = parseInt(stepNumber, 10);
103
+ if (!Number.isFinite(stepNum) || stepNum < 0) {
104
+ return c.json({ error: 'Invalid step number' }, 400);
105
+ }
106
+
107
+ const elapsedMs = Date.now() - job.startedAt;
108
+ const remainingBudgetMs = SANDBOX_LIMITS.maxJobDuration - elapsedMs;
109
+ if (remainingBudgetMs <= 0) {
110
+ await jobManager.failCloseJob(
111
+ jobId,
112
+ job,
113
+ `Job exceeded max duration (${SANDBOX_LIMITS.maxJobDuration}ms)`,
114
+ );
115
+ return c.json({
116
+ error: 'Job exceeded maximum duration',
117
+ elapsedMs,
118
+ maxDurationMs: SANDBOX_LIMITS.maxJobDuration,
119
+ }, 408);
120
+ }
121
+
122
+ const stepEnv = {
123
+ ...job.env,
124
+ ...body.env,
125
+ };
126
+
127
+ for (const [key, value] of Object.entries(job.secrets)) {
128
+ stepEnv[`GITHUB_SECRET_${key}`] = value;
129
+ }
130
+
131
+ const sensitiveStepValues = collectSensitiveEnvValues(stepEnv);
132
+ if (sensitiveStepValues.length > 0) {
133
+ job.secretsSanitizer.registerSecretValues(sensitiveStepValues);
134
+ }
135
+
136
+ const stepName = body.name || `Step ${stepNum}`;
137
+ pushLog(job.logs, `\n=== ${stepName} ===`, job.secretsSanitizer);
138
+
139
+ const workingDirectory = body['working-directory']
140
+ ? resolvePathWithin(job.workspacePath, body['working-directory'], 'working directory', true)
141
+ : job.workspacePath;
142
+ const executor = new StepExecutor(job.workspacePath, stepEnv);
143
+
144
+ const requestedTimeoutMs = body['timeout-minutes']
145
+ ? body['timeout-minutes'] * 60 * 1000
146
+ : SANDBOX_LIMITS.maxExecutionTime;
147
+ const timeoutMs = Math.max(
148
+ 1,
149
+ Math.min(requestedTimeoutMs, SANDBOX_LIMITS.maxExecutionTime, remainingBudgetMs)
150
+ );
151
+
152
+ let result: ExecutorStepResult;
153
+
154
+ if (body.run) {
155
+ // Block commands that would dump all environment variables (secrets included)
156
+ if (shouldBlockForSecretExposure(body.run)) {
157
+ const reason = mightExposeSecrets(body.run);
158
+ pushLog(job.logs, `[SECURITY] Command blocked: ${reason ?? 'may expose secrets'}`, job.secretsSanitizer);
159
+ return c.json({
160
+ error: `Command blocked for security: ${reason ?? 'may expose environment secrets'}`,
161
+ }, 400);
162
+ }
163
+
164
+ pushLog(
165
+ job.logs,
166
+ `Run: ${body.run.substring(0, 100)}${body.run.length > 100 ? '...' : ''}`,
167
+ job.secretsSanitizer
168
+ );
169
+ result = await executor.executeRun(body.run, timeoutMs, {
170
+ shell: body.shell,
171
+ workingDirectory,
172
+ });
173
+ } else if (body.uses) {
174
+ pushLog(job.logs, `Uses: ${body.uses}`, job.secretsSanitizer);
175
+ result = await executor.executeAction(
176
+ body.uses,
177
+ (body.with || {}) as Record<string, unknown>,
178
+ timeoutMs
179
+ );
180
+ } else {
181
+ return c.json({ error: 'Step must have either "run" or "uses"' }, 400);
182
+ }
183
+
184
+ const sanitizedStdout = job.secretsSanitizer.sanitize(result.stdout);
185
+ const sanitizedStderr = job.secretsSanitizer.sanitize(result.stderr);
186
+
187
+ if (sanitizedStdout) {
188
+ for (const line of sanitizedStdout.split('\n')) {
189
+ pushLog(job.logs, line, job.secretsSanitizer);
190
+ }
191
+ }
192
+ if (sanitizedStderr) {
193
+ for (const line of sanitizedStderr.split('\n')) {
194
+ pushLog(job.logs, `[stderr] ${line}`, job.secretsSanitizer);
195
+ }
196
+ }
197
+
198
+ const continueOnError = body['continue-on-error'] === true;
199
+ const { conclusion } = result;
200
+
201
+ if (conclusion === 'failure' && !continueOnError) {
202
+ pushLog(job.logs, `Step failed with exit code ${result.exitCode}`, job.secretsSanitizer);
203
+ } else if (conclusion === 'failure' && continueOnError) {
204
+ pushLog(job.logs, `Step failed but continuing (continue-on-error: true)`, job.secretsSanitizer);
205
+ } else {
206
+ pushLog(job.logs, `Step completed successfully`, job.secretsSanitizer);
207
+ }
208
+
209
+ const sanitizedOutputsMap = sanitizeOutputs(result.outputs, job.secretsSanitizer);
210
+ for (const [key, value] of Object.entries(sanitizedOutputsMap)) {
211
+ job.outputs[`step_${stepNum}_${key}`] = value;
212
+ }
213
+
214
+ job.currentStep = stepNum + 1;
215
+
216
+ return c.json({
217
+ exitCode: result.exitCode,
218
+ stdout: sanitizedStdout,
219
+ stderr: sanitizedStderr,
220
+ outputs: sanitizedOutputsMap,
221
+ conclusion: continueOnError && conclusion === 'failure' ? 'success' : conclusion,
222
+ });
223
+ } catch (err) {
224
+ c.get('log')?.error('Error executing step', { jobId, stepNumber, error: err });
225
+ return internalError(c, 'Step execution failed');
226
+ }
227
+ });
228
+
229
+ export default app;