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,274 @@
1
+ import { Hono } from 'hono';
2
+ import { runGitCommand } from '../../runtime/git.js';
3
+ import { validateGitAuthorName, validateGitAuthorEmail } from '../../runtime/validation.js';
4
+ import { mergeTempDirManager } from '../../utils/temp-dir.js';
5
+ import { getErrorMessage } from 'takos-common/errors';
6
+ import {
7
+ getVerifiedRepoPath,
8
+ validateRef,
9
+ resolveAndValidateWorkDir,
10
+ requireRepoParams,
11
+ } from './repo-validation.js';
12
+ import { isBoundaryViolationError } from '../../shared/errors.js';
13
+ import { badRequest, forbidden, internalError } from 'takos-common/middleware/hono';
14
+ import { ErrorCodes } from 'takos-common/errors';
15
+
16
+ const app = new Hono();
17
+
18
+ // ---------------------------------------------------------------------------
19
+ // commit + push
20
+ // ---------------------------------------------------------------------------
21
+
22
+ app.post('/repos/commit', async (c) => {
23
+ try {
24
+ const { workDir, message, author } = await c.req.json() as {
25
+ workDir: string;
26
+ message: string;
27
+ author?: { name: string; email: string };
28
+ };
29
+
30
+ if (!workDir || !message) {
31
+ return badRequest(c, 'workDir and message are required');
32
+ }
33
+
34
+ const workDirResult = await resolveAndValidateWorkDir(c, workDir);
35
+ if ('error' in workDirResult) return workDirResult.error;
36
+ const resolvedWorkDir = workDirResult.resolved;
37
+
38
+ const gitEnv: Record<string, string> = {};
39
+ if (author) {
40
+ try {
41
+ validateGitAuthorName(author.name);
42
+ validateGitAuthorEmail(author.email);
43
+ } catch (err) {
44
+ return badRequest(c, getErrorMessage(err));
45
+ }
46
+
47
+ gitEnv.GIT_AUTHOR_NAME = author.name;
48
+ gitEnv.GIT_AUTHOR_EMAIL = author.email;
49
+ gitEnv.GIT_COMMITTER_NAME = author.name;
50
+ gitEnv.GIT_COMMITTER_EMAIL = author.email;
51
+ }
52
+
53
+ const addResult = await runGitCommand(['add', '-A'], resolvedWorkDir, gitEnv);
54
+ if (addResult.exitCode !== 0) {
55
+ return internalError(c, 'Failed to stage changes', { output: addResult.output });
56
+ }
57
+
58
+ const statusResult = await runGitCommand(['status', '--porcelain'], resolvedWorkDir, gitEnv);
59
+ if (statusResult.output.trim() === '') {
60
+ return c.json({ success: true, message: 'No changes to commit', committed: false });
61
+ }
62
+
63
+ const commitResult = await runGitCommand(['commit', '-m', message], resolvedWorkDir, gitEnv);
64
+ if (commitResult.exitCode !== 0) {
65
+ return internalError(c, 'Failed to commit changes', { output: commitResult.output });
66
+ }
67
+
68
+ const hashResult = await runGitCommand(['rev-parse', 'HEAD'], resolvedWorkDir, gitEnv);
69
+
70
+ return c.json({
71
+ success: true,
72
+ committed: true,
73
+ commitHash: hashResult.output.trim(),
74
+ message: 'Changes committed successfully',
75
+ });
76
+ } catch (err) {
77
+ if (isBoundaryViolationError(err)) {
78
+ return forbidden(c, 'Path escapes workdir boundary');
79
+ }
80
+ return internalError(c, getErrorMessage(err));
81
+ }
82
+ });
83
+
84
+ app.post('/repos/push', async (c) => {
85
+ try {
86
+ const { workDir, branch } = await c.req.json() as {
87
+ workDir: string;
88
+ branch?: string;
89
+ };
90
+
91
+ if (!workDir) {
92
+ return badRequest(c, 'workDir is required');
93
+ }
94
+
95
+ const workDirResult = await resolveAndValidateWorkDir(c, workDir);
96
+ if ('error' in workDirResult) return workDirResult.error;
97
+ const resolvedWorkDir = workDirResult.resolved;
98
+
99
+ let branchToPush = branch;
100
+ if (!branchToPush) {
101
+ const branchResult = await runGitCommand(
102
+ ['rev-parse', '--abbrev-ref', 'HEAD'],
103
+ resolvedWorkDir
104
+ );
105
+ branchToPush = branchResult.output.trim() || 'main';
106
+ }
107
+
108
+ const refErr = validateRef(c, branchToPush);
109
+ if (refErr) return refErr;
110
+
111
+ const { exitCode, output } = await runGitCommand(
112
+ ['push', 'origin', branchToPush],
113
+ resolvedWorkDir
114
+ );
115
+
116
+ if (exitCode !== 0) {
117
+ return internalError(c, 'Failed to push to origin', { output });
118
+ }
119
+
120
+ return c.json({
121
+ success: true,
122
+ branch: branchToPush,
123
+ message: 'Pushed to origin successfully',
124
+ });
125
+ } catch (err) {
126
+ if (isBoundaryViolationError(err)) {
127
+ return forbidden(c, 'Path escapes workdir boundary');
128
+ }
129
+ return internalError(c, getErrorMessage(err));
130
+ }
131
+ });
132
+
133
+ // ---------------------------------------------------------------------------
134
+ // diff
135
+ // ---------------------------------------------------------------------------
136
+
137
+ app.get('/repos/:spaceId/:repoName/diff', async (c) => {
138
+ try {
139
+ const spaceId = c.req.param('spaceId');
140
+ const repoName = c.req.param('repoName');
141
+ const base = c.req.query('base');
142
+ const head = c.req.query('head');
143
+
144
+ const paramsErr = requireRepoParams(c, spaceId, repoName);
145
+ if (paramsErr) return paramsErr;
146
+
147
+ if (!base || !head) {
148
+ return badRequest(c, 'base and head query parameters are required');
149
+ }
150
+
151
+ const baseRefErr = validateRef(c, base);
152
+ if (baseRefErr) return baseRefErr;
153
+ const headRefErr = validateRef(c, head);
154
+ if (headRefErr) return headRefErr;
155
+
156
+ if (base.startsWith('--') || head.startsWith('--')) {
157
+ return badRequest(c, 'Invalid ref format');
158
+ }
159
+
160
+ const repoResult = await getVerifiedRepoPath(c, spaceId, repoName);
161
+ if ('error' in repoResult) return repoResult.error;
162
+ const gitPath = repoResult.gitPath;
163
+
164
+ const { exitCode, output } = await runGitCommand(['diff', `${base}...${head}`], gitPath);
165
+
166
+ if (exitCode !== 0) {
167
+ return internalError(c, 'Failed to generate diff', { output });
168
+ }
169
+
170
+ return c.json({ success: true, diff: output, base, head });
171
+ } catch (err) {
172
+ return internalError(c, getErrorMessage(err));
173
+ }
174
+ });
175
+
176
+ // ---------------------------------------------------------------------------
177
+ // merge
178
+ // ---------------------------------------------------------------------------
179
+
180
+ function isConflictLine(line: string): boolean {
181
+ return ['UU', 'AA', 'DD'].some((prefix) => line.startsWith(prefix));
182
+ }
183
+
184
+ app.post('/repos/merge', async (c) => {
185
+ try {
186
+ const { spaceId, repoName, sourceBranch, targetBranch, message } = await c.req.json() as {
187
+ spaceId: string;
188
+ repoName: string;
189
+ sourceBranch: string;
190
+ targetBranch: string;
191
+ message?: string;
192
+ };
193
+
194
+ if (!spaceId || !repoName || !sourceBranch || !targetBranch) {
195
+ return badRequest(c, 'spaceId, repoName, sourceBranch, and targetBranch are required');
196
+ }
197
+
198
+ const sourceRefErr = validateRef(c, sourceBranch);
199
+ if (sourceRefErr) return sourceRefErr;
200
+ const targetRefErr = validateRef(c, targetBranch);
201
+ if (targetRefErr) return targetRefErr;
202
+
203
+ const repoResult = await getVerifiedRepoPath(c, spaceId, repoName);
204
+ if ('error' in repoResult) return repoResult.error;
205
+ const gitPath = repoResult.gitPath;
206
+
207
+ const tempDir = await mergeTempDirManager.createTempDirWithCleanup(
208
+ `takos-merge-${spaceId.slice(0, 8)}-`
209
+ );
210
+
211
+ try {
212
+ const cloneResult = await runGitCommand(['clone', gitPath, tempDir], '/');
213
+ if (cloneResult.exitCode !== 0) {
214
+ return internalError(c, 'Failed to clone repository for merge', { output: cloneResult.output });
215
+ }
216
+
217
+ const checkoutResult = await runGitCommand(['checkout', targetBranch], tempDir);
218
+ if (checkoutResult.exitCode !== 0) {
219
+ return badRequest(c, `Failed to checkout target branch: ${targetBranch}`, { output: checkoutResult.output });
220
+ }
221
+
222
+ const mergeArgs = ['merge', sourceBranch, '--no-edit'];
223
+ if (message) {
224
+ if (typeof message !== 'string' || message.length > 4096) {
225
+ return badRequest(c, 'Merge message must be a string of at most 4096 characters');
226
+ }
227
+ if (/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/.test(message)) {
228
+ return badRequest(c, 'Merge message contains invalid control characters');
229
+ }
230
+ mergeArgs.push('-m', message);
231
+ }
232
+
233
+ const mergeResult = await runGitCommand(mergeArgs, tempDir);
234
+
235
+ if (mergeResult.exitCode !== 0) {
236
+ const statusResult = await runGitCommand(['status', '--porcelain'], tempDir);
237
+ const statusLines = statusResult.output.split('\n');
238
+ const conflictFiles = statusLines.filter(isConflictLine).map((line) => line.slice(3).trim());
239
+
240
+ if (conflictFiles.length > 0) {
241
+ await runGitCommand(['merge', '--abort'], tempDir);
242
+ return c.json({ error: { code: ErrorCodes.CONFLICT, message: 'Merge conflict', details: { conflicts: conflictFiles, output: mergeResult.output } } }, 409);
243
+ }
244
+
245
+ return internalError(c, 'Failed to merge branches', { output: mergeResult.output });
246
+ }
247
+
248
+ const hashResult = await runGitCommand(['rev-parse', 'HEAD'], tempDir);
249
+ const commitHash = hashResult.output.trim();
250
+
251
+ const pushResult = await runGitCommand(['push', 'origin', targetBranch], tempDir);
252
+ if (pushResult.exitCode !== 0) {
253
+ return internalError(c, 'Merge succeeded but failed to push to origin', {
254
+ commitHash,
255
+ output: pushResult.output,
256
+ });
257
+ }
258
+
259
+ return c.json({
260
+ success: true,
261
+ commitHash,
262
+ sourceBranch,
263
+ targetBranch,
264
+ message: `Successfully merged ${sourceBranch} into ${targetBranch}`,
265
+ });
266
+ } finally {
267
+ await mergeTempDirManager.cleanupTempDir(tempDir);
268
+ }
269
+ } catch (err) {
270
+ return internalError(c, getErrorMessage(err));
271
+ }
272
+ });
273
+
274
+ export default app;
@@ -0,0 +1,147 @@
1
+ import { Hono } from 'hono';
2
+ import {
3
+ MAX_EXEC_COMMANDS,
4
+ MAX_EXEC_FILE_BYTES,
5
+ MAX_EXEC_FILES,
6
+ MAX_EXEC_OUTPUTS,
7
+ MAX_EXEC_TOTAL_BYTES,
8
+ } from '../../shared/config.js';
9
+ import { writeAuditLog, type AuditEntry } from '../../utils/audit-log.js';
10
+ import { badRequest, forbidden, internalError, notFound } from 'takos-common/middleware/hono';
11
+ import { ErrorCodes } from 'takos-common/errors';
12
+ import { createLogger } from 'takos-common/logger';
13
+ import { hasSpaceScopeMismatch, SPACE_SCOPE_MISMATCH_ERROR } from '../../middleware/space-scope.js';
14
+ import { validateRuntimeExecEnv } from '../../utils/sandbox-env.js';
15
+
16
+ import {
17
+ type ExecInput,
18
+ getProcess,
19
+ isSpaceConcurrencyExceeded,
20
+ ensureProcessCapacity,
21
+ sanitizeErrorMessage,
22
+ runExec,
23
+ } from '../../runtime/exec-runner.js';
24
+
25
+ const logger = createLogger({ service: 'takos-runtime' });
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // Helpers
29
+ // ---------------------------------------------------------------------------
30
+
31
+ function fireAuditLog(entry: AuditEntry): void {
32
+ void writeAuditLog(entry).catch((err: unknown) => logger.error('[audit] writeAuditLog failed', { error: err }));
33
+ }
34
+
35
+ /**
36
+ * Validate the exec request body. Returns an error string if invalid, null if valid.
37
+ */
38
+ function validateExecBody(body: ExecInput): string | null {
39
+ if (!body.space_id || !body.commands || body.commands.length === 0) {
40
+ return 'Missing required fields: space_id, commands';
41
+ }
42
+ if (body.commands.length > MAX_EXEC_COMMANDS) {
43
+ return `Too many commands (max ${MAX_EXEC_COMMANDS})`;
44
+ }
45
+ if (body.files && body.files.length > MAX_EXEC_FILES) {
46
+ return `Too many files (max ${MAX_EXEC_FILES})`;
47
+ }
48
+ if (body.return_outputs && body.return_outputs.length > MAX_EXEC_OUTPUTS) {
49
+ return `Too many output files requested (max ${MAX_EXEC_OUTPUTS})`;
50
+ }
51
+ if (body.files && body.files.length > 0) {
52
+ let totalBytes = 0;
53
+ for (const file of body.files) {
54
+ const bytes = Buffer.byteLength(file.content ?? '', 'utf-8');
55
+ if (bytes > MAX_EXEC_FILE_BYTES) {
56
+ return `File too large: ${file.path}`;
57
+ }
58
+ totalBytes += bytes;
59
+ if (totalBytes > MAX_EXEC_TOTAL_BYTES) {
60
+ return 'Total file size exceeded';
61
+ }
62
+ }
63
+ }
64
+ return null;
65
+ }
66
+
67
+ // ---------------------------------------------------------------------------
68
+ // Router
69
+ // ---------------------------------------------------------------------------
70
+
71
+ const app = new Hono();
72
+
73
+ app.post('/exec', async (c) => {
74
+ const execStartTime = Date.now();
75
+ try {
76
+ const body = await c.req.json() as ExecInput;
77
+
78
+ const validationError = validateExecBody(body);
79
+ if (validationError) {
80
+ return badRequest(c, validationError);
81
+ }
82
+
83
+ if (hasSpaceScopeMismatch(c, body.space_id)) {
84
+ return forbidden(c, SPACE_SCOPE_MISMATCH_ERROR);
85
+ }
86
+
87
+ // Validate user-supplied environment variables
88
+ if (body.env) {
89
+ const envValidation = validateRuntimeExecEnv(body.env as Record<string, string> | undefined);
90
+ if (envValidation.ok === false) {
91
+ return badRequest(c, envValidation.error);
92
+ }
93
+ }
94
+
95
+ const auditBase: Omit<AuditEntry, 'timestamp' | 'status'> = {
96
+ event: 'exec',
97
+ spaceId: body.space_id,
98
+ commands: body.commands,
99
+ ip: c.req.header('x-forwarded-for') || 'unknown',
100
+ requestId: c.get('requestId'),
101
+ };
102
+
103
+ fireAuditLog({ ...auditBase, timestamp: new Date().toISOString(), status: 'started' });
104
+
105
+ if (isSpaceConcurrencyExceeded(body.space_id)) {
106
+ return c.json({ error: { code: ErrorCodes.RATE_LIMITED, message: 'Space concurrency limit reached (max concurrent executions)' } }, 429);
107
+ }
108
+
109
+ if (!ensureProcessCapacity()) {
110
+ return c.json({ error: { code: ErrorCodes.SERVICE_UNAVAILABLE, message: 'Server at capacity. Please try again later.' } }, 503);
111
+ }
112
+
113
+ const result = await runExec(body);
114
+
115
+ fireAuditLog({
116
+ ...auditBase,
117
+ timestamp: new Date().toISOString(),
118
+ exitCode: result.exit_code,
119
+ durationMs: Date.now() - execStartTime,
120
+ status: result.status === 'completed' ? 'completed' : 'failed',
121
+ error: result.error,
122
+ });
123
+
124
+ return c.json(result);
125
+ } catch (err) {
126
+ c.get('log')?.error('Exec error', { error: err });
127
+ return internalError(c, sanitizeErrorMessage(err));
128
+ }
129
+ });
130
+
131
+ app.get('/status/:id', (c) => {
132
+ const proc = getProcess(c.req.param('id'));
133
+
134
+ if (!proc) {
135
+ return notFound(c, 'Process not found');
136
+ }
137
+
138
+ return c.json({
139
+ runtime_id: proc.id,
140
+ status: proc.status,
141
+ output: proc.output,
142
+ error: proc.error,
143
+ exit_code: proc.exit_code,
144
+ });
145
+ });
146
+
147
+ export default app;
@@ -0,0 +1,113 @@
1
+ import { Hono } from 'hono';
2
+ import { Worker } from 'worker_threads';
3
+ import { existsSync } from 'fs';
4
+ import path from 'path';
5
+ import { TOOL_NAME_PATTERN, DEFAULT_TIMEOUT_MS, MAX_TIMEOUT_MS } from '../../shared/config.js';
6
+ import { getWorkerResourceLimits } from '../../runtime/validation.js';
7
+ import { getErrorMessage } from 'takos-common/errors';
8
+ import { badRequest } from 'takos-common/middleware/hono';
9
+ import { createLogger } from 'takos-common/logger';
10
+ const logger = createLogger({ service: 'takos-runtime' });
11
+
12
+ interface ExecuteToolRequest {
13
+ code: string;
14
+ toolName: string;
15
+ parameters: Record<string, unknown>;
16
+ secrets: Record<string, string>;
17
+ config: Record<string, unknown>;
18
+ permissions: {
19
+ allowedDomains: string[];
20
+ filePermission: 'read' | 'write' | 'none';
21
+ };
22
+ timeout?: number;
23
+ maxMemory?: number;
24
+ }
25
+
26
+ interface ToolResult {
27
+ success: boolean;
28
+ output: string;
29
+ error?: string;
30
+ executionTime: number;
31
+ }
32
+
33
+ function resolveWorkerPath(): string {
34
+ const jsPath = path.join(__dirname, '../../runtime/tools/worker.js');
35
+ return existsSync(jsPath) ? jsPath : path.join(__dirname, '../../runtime/tools/worker.ts');
36
+ }
37
+
38
+ const app = new Hono();
39
+
40
+ app.post('/execute-tool', async (c) => {
41
+ const startTime = Date.now();
42
+ const body = await c.req.json() as ExecuteToolRequest;
43
+
44
+ if (!body.code || !body.toolName) {
45
+ return badRequest(c, 'Missing required fields: code, toolName');
46
+ }
47
+
48
+ if (!TOOL_NAME_PATTERN.test(body.toolName)) {
49
+ return badRequest(c, 'Invalid toolName format');
50
+ }
51
+
52
+ const timeout = Math.min(body.timeout || DEFAULT_TIMEOUT_MS, MAX_TIMEOUT_MS);
53
+
54
+ try {
55
+ const result = await new Promise<ToolResult>((resolve, reject) => {
56
+ const worker = new Worker(resolveWorkerPath(), {
57
+ execArgv: process.execArgv,
58
+ resourceLimits: getWorkerResourceLimits(body.maxMemory),
59
+ });
60
+ let settled = false;
61
+
62
+ const hardTimeout = setTimeout(() => {
63
+ settle(() => reject(new Error(`Execution timed out after ${timeout}ms`)));
64
+ }, timeout + 1_000 /* hard timeout margin */);
65
+
66
+ function settle(action: () => void): void {
67
+ if (settled) return;
68
+ settled = true;
69
+ clearTimeout(hardTimeout);
70
+ // Await termination to ensure clean resource cleanup
71
+ worker.terminate().catch((err) => { logger.warn('Worker terminate failed (non-critical)', { module: 'runtime/tools', error: err }); });
72
+ action();
73
+ }
74
+
75
+ worker.on('message', (message) => {
76
+ settle(() => resolve(message as ToolResult));
77
+ });
78
+
79
+ worker.on('error', (err) => {
80
+ settle(() => reject(err));
81
+ });
82
+
83
+ worker.on('exit', (code) => {
84
+ if (code !== 0) {
85
+ settle(() => reject(new Error(`Tool worker exited with code ${code}`)));
86
+ }
87
+ });
88
+
89
+ worker.postMessage({
90
+ code: body.code,
91
+ toolName: body.toolName,
92
+ parameters: body.parameters,
93
+ secrets: body.secrets,
94
+ config: body.config,
95
+ allowedDomains: body.permissions?.allowedDomains || [],
96
+ timeout,
97
+ });
98
+ });
99
+
100
+ return c.json({
101
+ ...result,
102
+ executionTime: result.executionTime || Date.now() - startTime,
103
+ });
104
+ } catch (err) {
105
+ return c.json({
106
+ error: getErrorMessage(err),
107
+ output: '',
108
+ executionTime: Date.now() - startTime,
109
+ });
110
+ }
111
+ });
112
+
113
+ export default app;