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,450 @@
1
+ import * as fs from 'fs/promises';
2
+ import type { Dirent } from 'fs';
3
+ import * as path from 'path';
4
+ import * as os from 'os';
5
+ import { createHash } from 'crypto';
6
+ import { parse as parseYaml } from 'yaml';
7
+ import type { ActionRuns, ActionOutputDefinition } from './composite-executor.js';
8
+ import { cloneAndCheckout } from '../git.js';
9
+ import { createLogger } from 'takos-common/logger';
10
+
11
+ const logger = createLogger({ service: 'takos-runtime' });
12
+
13
+ // ===========================================================================
14
+ // --- Action metadata loading ---
15
+ // ===========================================================================
16
+
17
+ // ---------------------------------------------------------------------------
18
+ // Types
19
+ // ---------------------------------------------------------------------------
20
+
21
+ export interface ActionInputDefinition {
22
+ description?: string;
23
+ required?: boolean;
24
+ default?: unknown;
25
+ }
26
+
27
+ export interface ActionMetadata {
28
+ name?: string;
29
+ description?: string;
30
+ inputs?: Record<string, ActionInputDefinition>;
31
+ outputs?: Record<string, ActionOutputDefinition>;
32
+ runs?: ActionRuns;
33
+ }
34
+
35
+ // ---------------------------------------------------------------------------
36
+ // Action metadata loading
37
+ // ---------------------------------------------------------------------------
38
+
39
+ const ALLOWED_ACTION_KEYS = new Set([
40
+ 'name', 'author', 'description', 'branding',
41
+ 'inputs', 'outputs', 'runs',
42
+ ]);
43
+
44
+ export async function loadActionMetadata(actionDir: string): Promise<ActionMetadata> {
45
+ const actionYmlPath = path.join(actionDir, 'action.yml');
46
+ const actionYamlPath = path.join(actionDir, 'action.yaml');
47
+
48
+ let actionContent: string;
49
+ try {
50
+ actionContent = await fs.readFile(actionYmlPath, 'utf-8');
51
+ } catch {
52
+ actionContent = await fs.readFile(actionYamlPath, 'utf-8');
53
+ }
54
+
55
+ const parsed = parseYaml(actionContent);
56
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
57
+ throw new Error('Invalid action.yml format: expected a YAML mapping');
58
+ }
59
+
60
+ const record = parsed as Record<string, unknown>;
61
+
62
+ // Reject unexpected top-level keys to mitigate malicious YAML payloads
63
+ for (const key of Object.keys(record)) {
64
+ if (!ALLOWED_ACTION_KEYS.has(key)) {
65
+ throw new Error(`Invalid action.yml: unexpected top-level key '${key}'`);
66
+ }
67
+ }
68
+
69
+ // Validate runs structure
70
+ if (record.runs !== undefined) {
71
+ if (typeof record.runs !== 'object' || record.runs === null || Array.isArray(record.runs)) {
72
+ throw new Error('Invalid action.yml: "runs" must be an object');
73
+ }
74
+ const runs = record.runs as Record<string, unknown>;
75
+ if (typeof runs.using !== 'string') {
76
+ throw new Error('Invalid action.yml: "runs.using" must be a string');
77
+ }
78
+ }
79
+
80
+ // Validate inputs structure
81
+ if (record.inputs !== undefined) {
82
+ if (typeof record.inputs !== 'object' || record.inputs === null || Array.isArray(record.inputs)) {
83
+ throw new Error('Invalid action.yml: "inputs" must be an object');
84
+ }
85
+ }
86
+
87
+ // Validate outputs structure
88
+ if (record.outputs !== undefined) {
89
+ if (typeof record.outputs !== 'object' || record.outputs === null || Array.isArray(record.outputs)) {
90
+ throw new Error('Invalid action.yml: "outputs" must be an object');
91
+ }
92
+ }
93
+
94
+ return record as ActionMetadata;
95
+ }
96
+
97
+ // ---------------------------------------------------------------------------
98
+ // Action reference parsing
99
+ // ---------------------------------------------------------------------------
100
+
101
+ export function parseActionRef(action: string): { owner: string; repo: string; actionPath: string; ref: string } {
102
+ const atIndex = action.indexOf('@');
103
+ const refPart = atIndex >= 0 ? action.slice(atIndex + 1) : 'main';
104
+ const pathPart = atIndex >= 0 ? action.slice(0, atIndex) : action;
105
+ const parts = pathPart.split('/');
106
+
107
+ return {
108
+ owner: parts[0] || '',
109
+ repo: parts[1] || '',
110
+ actionPath: parts.slice(2).join('/'),
111
+ ref: refPart || 'main',
112
+ };
113
+ }
114
+
115
+ // ---------------------------------------------------------------------------
116
+ // Action component validation
117
+ // ---------------------------------------------------------------------------
118
+
119
+ export function validateActionComponent(value: string, label: string): void {
120
+ if (!/^[A-Za-z0-9_.-]+$/.test(value)) {
121
+ throw new Error(`Invalid action ${label}: ${value}`);
122
+ }
123
+ }
124
+
125
+ // ---------------------------------------------------------------------------
126
+ // Input resolution
127
+ // ---------------------------------------------------------------------------
128
+
129
+ function normalizeInputValue(value: unknown): string {
130
+ if (value === null || value === undefined) return '';
131
+ if (typeof value === 'boolean') return value ? 'true' : 'false';
132
+ return String(value);
133
+ }
134
+
135
+ export function resolveInputs(
136
+ definitions: Record<string, ActionInputDefinition> | undefined,
137
+ provided: Record<string, unknown>
138
+ ): { resolvedInputs: Record<string, string>; missing: string[] } {
139
+ const resolvedInputs: Record<string, string> = {};
140
+ const missing: string[] = [];
141
+ const providedMap = new Map<string, { key: string; value: unknown }>();
142
+
143
+ for (const [key, value] of Object.entries(provided || {})) {
144
+ providedMap.set(key.toLowerCase(), { key, value });
145
+ }
146
+
147
+ const definedKeys = new Set<string>();
148
+
149
+ if (definitions) {
150
+ for (const [name, def] of Object.entries(definitions)) {
151
+ const normalized = name.toLowerCase();
152
+ definedKeys.add(normalized);
153
+
154
+ let value = providedMap.get(normalized)?.value;
155
+ if (value === undefined) {
156
+ if (def && Object.prototype.hasOwnProperty.call(def, 'default')) {
157
+ value = def.default;
158
+ } else if (def?.required) {
159
+ missing.push(name);
160
+ }
161
+ }
162
+
163
+ if (value !== undefined) {
164
+ resolvedInputs[name] = normalizeInputValue(value);
165
+ }
166
+ }
167
+ }
168
+
169
+ for (const [key, value] of Object.entries(provided || {})) {
170
+ if (!definedKeys.has(key.toLowerCase())) {
171
+ resolvedInputs[key] = normalizeInputValue(value);
172
+ }
173
+ }
174
+
175
+ return { resolvedInputs, missing };
176
+ }
177
+
178
+ export function buildInputEnv(inputs: Record<string, string>): Record<string, string> {
179
+ const env: Record<string, string> = {};
180
+ for (const [key, value] of Object.entries(inputs)) {
181
+ env[`INPUT_${key.replace(/[^A-Za-z0-9_]/g, '_').toUpperCase()}`] = value;
182
+ }
183
+ return env;
184
+ }
185
+
186
+ // ===========================================================================
187
+ // --- Action cache & marketplace fetching ---
188
+ // ===========================================================================
189
+
190
+ // ---------------------------------------------------------------------------
191
+ // Constants
192
+ // ---------------------------------------------------------------------------
193
+
194
+ export const ACTION_CACHE_DIR = path.join(os.tmpdir(), 'takos-actions-cache');
195
+ const ACTION_CACHE_MAX_ENTRIES = 30;
196
+ const ACTION_CACHE_MAX_BYTES = 2 * 1024 * 1024 * 1024; // 2GB
197
+ const GET_DIR_SIZE_MAX_DEPTH = 10;
198
+
199
+ // ---------------------------------------------------------------------------
200
+ // Module-level state
201
+ // ---------------------------------------------------------------------------
202
+
203
+ const actionRepoCache = new Map<string, Promise<string>>();
204
+ /** Per-action mutex to prevent concurrent fetch race conditions. */
205
+ const actionFetchLocks = new Map<string, Promise<string>>();
206
+ let actionCachePrunePromise: Promise<void> | null = null;
207
+
208
+ // ---------------------------------------------------------------------------
209
+ // Symlink safety
210
+ // ---------------------------------------------------------------------------
211
+
212
+ async function removeEscapingSymlinks(dir: string, boundary: string): Promise<void> {
213
+ const resolvedBoundary = path.resolve(boundary);
214
+ let entries: Array<Dirent>;
215
+ try {
216
+ entries = await fs.readdir(dir, { withFileTypes: true });
217
+ } catch {
218
+ return;
219
+ }
220
+
221
+ for (const entry of entries) {
222
+ const entryPath = path.join(dir, entry.name);
223
+ try {
224
+ const lstats = await fs.lstat(entryPath);
225
+ if (lstats.isSymbolicLink()) {
226
+ const target = await fs.realpath(entryPath).catch(() => null);
227
+ const isWithinBoundary = target !== null
228
+ && (target === resolvedBoundary || target.startsWith(resolvedBoundary + path.sep));
229
+ if (!isWithinBoundary) {
230
+ await fs.unlink(entryPath).catch((e) => {
231
+ logger.warn('Failed to unlink escaping symlink (non-critical)', { module: 'action-registry', path: entryPath, error: e });
232
+ });
233
+ }
234
+ } else if (lstats.isDirectory()) {
235
+ await removeEscapingSymlinks(entryPath, boundary);
236
+ }
237
+ } catch (e) {
238
+ logger.warn('Failed to stat entry during symlink cleanup (non-critical)', { module: 'action-registry', path: entryPath, error: e });
239
+ }
240
+ }
241
+ }
242
+
243
+ // ---------------------------------------------------------------------------
244
+ // Cache size management
245
+ // ---------------------------------------------------------------------------
246
+
247
+ function evictActionRepoCache(): void {
248
+ if (actionRepoCache.size <= ACTION_CACHE_MAX_ENTRIES) return;
249
+ const toDelete = actionRepoCache.size - ACTION_CACHE_MAX_ENTRIES;
250
+ let deleted = 0;
251
+ for (const key of actionRepoCache.keys()) {
252
+ if (deleted >= toDelete) break;
253
+ actionRepoCache.delete(key);
254
+ deleted++;
255
+ }
256
+ }
257
+
258
+ async function getDirectorySize(
259
+ targetPath: string,
260
+ depth: number = 0,
261
+ visited: Set<string> = new Set()
262
+ ): Promise<number> {
263
+ if (depth >= GET_DIR_SIZE_MAX_DEPTH) {
264
+ return 0;
265
+ }
266
+
267
+ let realPath: string;
268
+ try {
269
+ realPath = await fs.realpath(targetPath);
270
+ } catch {
271
+ return 0;
272
+ }
273
+ if (visited.has(realPath)) {
274
+ return 0;
275
+ }
276
+ visited.add(realPath);
277
+
278
+ let total = 0;
279
+ let entries: Array<Dirent>;
280
+ try {
281
+ entries = await fs.readdir(targetPath, { withFileTypes: true });
282
+ } catch {
283
+ return 0;
284
+ }
285
+
286
+ for (const entry of entries) {
287
+ const entryPath = path.join(targetPath, entry.name);
288
+ try {
289
+ if (entry.isSymbolicLink()) {
290
+ continue;
291
+ }
292
+ if (entry.isDirectory()) {
293
+ total += await getDirectorySize(entryPath, depth + 1, visited);
294
+ } else if (entry.isFile()) {
295
+ const stats = await fs.stat(entryPath);
296
+ total += stats.size;
297
+ }
298
+ } catch {
299
+ // Ignore inaccessible entries
300
+ }
301
+ }
302
+
303
+ return total;
304
+ }
305
+
306
+ async function pruneActionCache(keepPaths: string[] = []): Promise<void> {
307
+ if (actionCachePrunePromise) {
308
+ await actionCachePrunePromise;
309
+ return;
310
+ }
311
+
312
+ actionCachePrunePromise = (async () => {
313
+ let entries: Array<Dirent>;
314
+ try {
315
+ entries = await fs.readdir(ACTION_CACHE_DIR, { withFileTypes: true });
316
+ } catch {
317
+ return;
318
+ }
319
+
320
+ const cacheEntries: Array<{ path: string; mtime: number; size: number }> = [];
321
+
322
+ for (const entry of entries) {
323
+ if (!entry.isDirectory()) continue;
324
+ const entryPath = path.join(ACTION_CACHE_DIR, entry.name);
325
+ if (keepPaths.includes(entryPath)) {
326
+ continue;
327
+ }
328
+ try {
329
+ const stats = await fs.stat(entryPath);
330
+ const size = await getDirectorySize(entryPath);
331
+ cacheEntries.push({ path: entryPath, mtime: stats.mtimeMs, size });
332
+ } catch {
333
+ // Ignore entries we cannot stat
334
+ }
335
+ }
336
+
337
+ if (cacheEntries.length === 0) return;
338
+
339
+ cacheEntries.sort((a, b) => a.mtime - b.mtime);
340
+
341
+ let totalSize = cacheEntries.reduce((sum, entry) => sum + entry.size, 0);
342
+ let totalEntries = cacheEntries.length;
343
+
344
+ for (const entry of cacheEntries) {
345
+ if (totalEntries <= ACTION_CACHE_MAX_ENTRIES && totalSize <= ACTION_CACHE_MAX_BYTES) {
346
+ break;
347
+ }
348
+ try {
349
+ await fs.rm(entry.path, { recursive: true, force: true });
350
+ } catch {
351
+ // Ignore removal errors
352
+ }
353
+ totalEntries -= 1;
354
+ totalSize -= entry.size;
355
+ }
356
+ })();
357
+
358
+ try {
359
+ await actionCachePrunePromise;
360
+ } finally {
361
+ actionCachePrunePromise = null;
362
+ }
363
+ }
364
+
365
+ // ---------------------------------------------------------------------------
366
+ // Marketplace repo fetching
367
+ // ---------------------------------------------------------------------------
368
+
369
+ export interface ActionRefInfo {
370
+ owner: string;
371
+ repo: string;
372
+ actionPath: string;
373
+ ref: string;
374
+ }
375
+
376
+ export async function fetchMarketplaceRepo(
377
+ actionRef: ActionRefInfo,
378
+ env: Record<string, string>
379
+ ): Promise<string> {
380
+ const cacheKey = `${actionRef.owner}/${actionRef.repo}@${actionRef.ref}`;
381
+
382
+ // Check if a resolved path is already cached
383
+ const cachedPromise = actionRepoCache.get(cacheKey);
384
+ if (cachedPromise) {
385
+ return cachedPromise;
386
+ }
387
+
388
+ // Use a per-action mutex to prevent concurrent fetches of the same action
389
+ // from racing on filesystem operations.
390
+ const existingLock = actionFetchLocks.get(cacheKey);
391
+ if (existingLock) {
392
+ return existingLock;
393
+ }
394
+
395
+ const fetchPromise = (async () => {
396
+ await fs.mkdir(ACTION_CACHE_DIR, { recursive: true });
397
+ const hash = createHash('sha256').update(cacheKey).digest('hex').slice(0, 16);
398
+ const repoDir = path.join(ACTION_CACHE_DIR, `${actionRef.owner}-${actionRef.repo}-${hash}`);
399
+
400
+ const gitDir = path.join(repoDir, '.git');
401
+ const gitExists = await fs.stat(gitDir).then(() => true).catch(() => false);
402
+
403
+ if (!gitExists) {
404
+ await fs.rm(repoDir, { recursive: true, force: true });
405
+ await fs.mkdir(repoDir, { recursive: true });
406
+
407
+ const cloneResult = await cloneAndCheckout({
408
+ repoUrl: `https://github.com/${actionRef.owner}/${actionRef.repo}.git`,
409
+ targetDir: repoDir,
410
+ ref: actionRef.ref,
411
+ shallow: true,
412
+ env,
413
+ });
414
+
415
+ if (!cloneResult.success) {
416
+ await fs.rm(repoDir, { recursive: true, force: true });
417
+ throw new Error(`Failed to fetch action ${cacheKey}: ${cloneResult.output}`);
418
+ }
419
+
420
+ await removeEscapingSymlinks(repoDir, repoDir);
421
+ }
422
+
423
+ try {
424
+ const now = new Date();
425
+ await fs.utimes(repoDir, now, now);
426
+ } catch {
427
+ // Ignore utimes errors
428
+ }
429
+
430
+ await pruneActionCache([repoDir]);
431
+ return repoDir;
432
+ })();
433
+
434
+ actionFetchLocks.set(cacheKey, fetchPromise);
435
+
436
+ try {
437
+ const result = await fetchPromise;
438
+ // Cache the resolved path for future lookups
439
+ actionRepoCache.set(cacheKey, Promise.resolve(result));
440
+ evictActionRepoCache();
441
+ return result;
442
+ } catch (err) {
443
+ // Don't cache failures — allow retry
444
+ actionRepoCache.delete(cacheKey);
445
+ throw err;
446
+ } finally {
447
+ // Always release the fetch lock
448
+ actionFetchLocks.delete(cacheKey);
449
+ }
450
+ }
@@ -0,0 +1,31 @@
1
+ import type { ExecutorStepResult } from './executor.js';
2
+
3
+ /**
4
+ * Append a step result's stdout/stderr to accumulator arrays.
5
+ */
6
+ export function appendOutput(
7
+ result: ExecutorStepResult,
8
+ stdoutParts: string[],
9
+ stderrParts: string[]
10
+ ): void {
11
+ if (result.stdout) stdoutParts.push(result.stdout);
12
+ if (result.stderr) stderrParts.push(result.stderr);
13
+ }
14
+
15
+ /**
16
+ * Build a combined ExecutorStepResult from accumulated stdout/stderr parts.
17
+ */
18
+ export function buildCombinedResult(
19
+ stdoutParts: string[],
20
+ stderrParts: string[],
21
+ outputs: Record<string, string>,
22
+ conclusion: 'success' | 'failure'
23
+ ): ExecutorStepResult {
24
+ return {
25
+ exitCode: conclusion === 'success' ? 0 : 1,
26
+ stdout: stdoutParts.join('\n').trimEnd(),
27
+ stderr: stderrParts.join('\n').trimEnd(),
28
+ outputs,
29
+ conclusion,
30
+ };
31
+ }