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,160 @@
1
+ import { Hono } from 'hono';
2
+ import { runGitCommand } from '../../runtime/git.js';
3
+ import { getErrorMessage } from 'takos-common/errors';
4
+ import {
5
+ getVerifiedRepoPath,
6
+ validateRef,
7
+ requireRepoParams,
8
+ } from './repo-validation.js';
9
+ import { badRequest, internalError, notFound } from 'takos-common/middleware/hono';
10
+ import { ErrorCodes } from 'takos-common/errors';
11
+
12
+ const app = new Hono();
13
+
14
+ // ---------------------------------------------------------------------------
15
+ // branches: list, create, delete
16
+ // ---------------------------------------------------------------------------
17
+
18
+ app.get('/repos/:spaceId/:repoName/branches', async (c) => {
19
+ try {
20
+ const spaceId = c.req.param('spaceId');
21
+ const repoName = c.req.param('repoName');
22
+
23
+ const paramsErr = requireRepoParams(c, spaceId, repoName);
24
+ if (paramsErr) return paramsErr;
25
+
26
+ const repoResult = await getVerifiedRepoPath(c, spaceId, repoName);
27
+ if ('error' in repoResult) return repoResult.error;
28
+ const gitPath = repoResult.gitPath;
29
+
30
+ const { exitCode, output } = await runGitCommand(
31
+ ['branch', '--list', '--format=%(refname:short)'],
32
+ gitPath
33
+ );
34
+
35
+ if (exitCode !== 0) {
36
+ return internalError(c, 'Failed to list branches', { output });
37
+ }
38
+
39
+ const branches = output
40
+ .split('\n')
41
+ .map((branch) => branch.trim())
42
+ .filter((branch) => branch.length > 0);
43
+
44
+ return c.json({ success: true, branches });
45
+ } catch (err) {
46
+ return internalError(c, getErrorMessage(err));
47
+ }
48
+ });
49
+
50
+ app.post('/repos/branch', async (c) => {
51
+ try {
52
+ const { spaceId, repoName, branchName, fromRef } = await c.req.json() as {
53
+ spaceId: string;
54
+ repoName: string;
55
+ branchName: string;
56
+ fromRef?: string;
57
+ };
58
+
59
+ if (!spaceId || !repoName || !branchName) {
60
+ return badRequest(c, 'spaceId, repoName, and branchName are required');
61
+ }
62
+
63
+ const refErr = validateRef(c, branchName);
64
+ if (refErr) return refErr;
65
+ if (fromRef) {
66
+ const fromRefErr = validateRef(c, fromRef);
67
+ if (fromRefErr) return fromRefErr;
68
+ }
69
+
70
+ const repoResult = await getVerifiedRepoPath(c, spaceId, repoName);
71
+ if ('error' in repoResult) return repoResult.error;
72
+ const gitPath = repoResult.gitPath;
73
+
74
+ const branchCheckResult = await runGitCommand(
75
+ ['show-ref', '--verify', `refs/heads/${branchName}`],
76
+ gitPath
77
+ );
78
+ if (branchCheckResult.exitCode === 0) {
79
+ return c.json({ error: { code: ErrorCodes.CONFLICT, message: `Branch '${branchName}' already exists` } }, 409);
80
+ }
81
+
82
+ const sourceRef = fromRef || 'HEAD';
83
+ const resolveResult = await runGitCommand(['rev-parse', sourceRef], gitPath);
84
+ if (resolveResult.exitCode !== 0) {
85
+ return badRequest(c, `Invalid reference: ${sourceRef}`, { output: resolveResult.output });
86
+ }
87
+
88
+ const commitHash = resolveResult.output.trim();
89
+ const createResult = await runGitCommand(
90
+ ['update-ref', `refs/heads/${branchName}`, commitHash],
91
+ gitPath
92
+ );
93
+
94
+ if (createResult.exitCode !== 0) {
95
+ return internalError(c, 'Failed to create branch', { output: createResult.output });
96
+ }
97
+
98
+ return c.json({
99
+ success: true,
100
+ branchName,
101
+ commitHash,
102
+ fromRef: sourceRef,
103
+ message: `Branch '${branchName}' created successfully`,
104
+ });
105
+ } catch (err) {
106
+ return internalError(c, getErrorMessage(err));
107
+ }
108
+ });
109
+
110
+ app.delete('/repos/branch', async (c) => {
111
+ try {
112
+ const { spaceId, repoName, branchName } = await c.req.json() as {
113
+ spaceId: string;
114
+ repoName: string;
115
+ branchName: string;
116
+ };
117
+
118
+ if (!spaceId || !repoName || !branchName) {
119
+ return badRequest(c, 'spaceId, repoName, and branchName are required');
120
+ }
121
+
122
+ const refErr = validateRef(c, branchName);
123
+ if (refErr) return refErr;
124
+
125
+ if (branchName === 'main' || branchName === 'master') {
126
+ return badRequest(c, `Cannot delete protected branch: ${branchName}`);
127
+ }
128
+
129
+ const repoResult = await getVerifiedRepoPath(c, spaceId, repoName);
130
+ if ('error' in repoResult) return repoResult.error;
131
+ const gitPath = repoResult.gitPath;
132
+
133
+ const branchCheckResult = await runGitCommand(
134
+ ['show-ref', '--verify', `refs/heads/${branchName}`],
135
+ gitPath
136
+ );
137
+ if (branchCheckResult.exitCode !== 0) {
138
+ return notFound(c, `Branch '${branchName}' not found`);
139
+ }
140
+
141
+ const deleteResult = await runGitCommand(
142
+ ['update-ref', '-d', `refs/heads/${branchName}`],
143
+ gitPath
144
+ );
145
+
146
+ if (deleteResult.exitCode !== 0) {
147
+ return internalError(c, 'Failed to delete branch', { output: deleteResult.output });
148
+ }
149
+
150
+ return c.json({
151
+ success: true,
152
+ branchName,
153
+ message: `Branch '${branchName}' deleted successfully`,
154
+ });
155
+ } catch (err) {
156
+ return internalError(c, getErrorMessage(err));
157
+ }
158
+ });
159
+
160
+ export default app;
@@ -0,0 +1,209 @@
1
+ import { Hono } from 'hono';
2
+ import { runGitCommand } from '../../runtime/git.js';
3
+ import { getErrorMessage } from 'takos-common/errors';
4
+ import {
5
+ getVerifiedRepoPath,
6
+ validateRef,
7
+ validatePathParam,
8
+ requireRepoParams,
9
+ } from './repo-validation.js';
10
+ import { badRequest, internalError, notFound } from 'takos-common/middleware/hono';
11
+
12
+ const app = new Hono();
13
+
14
+ // ---------------------------------------------------------------------------
15
+ // tree: ls-tree + blob
16
+ // ---------------------------------------------------------------------------
17
+
18
+ app.get('/repos/:spaceId/:repoName/tree', async (c) => {
19
+ try {
20
+ const spaceId = c.req.param('spaceId');
21
+ const repoName = c.req.param('repoName');
22
+ const ref = c.req.query('ref') || 'HEAD';
23
+ const treePath = c.req.query('path') || '';
24
+
25
+ const paramsErr = requireRepoParams(c, spaceId, repoName);
26
+ if (paramsErr) return paramsErr;
27
+ const refErr = validateRef(c, ref);
28
+ if (refErr) return refErr;
29
+ if (treePath) {
30
+ const pathErr = validatePathParam(c, treePath);
31
+ if (pathErr) return pathErr;
32
+ }
33
+
34
+ const repoResult = await getVerifiedRepoPath(c, spaceId, repoName);
35
+ if ('error' in repoResult) return repoResult.error;
36
+ const gitPath = repoResult.gitPath;
37
+
38
+ const lsTreeArgs = ['ls-tree', '-l', ref];
39
+ if (treePath) {
40
+ lsTreeArgs.push('--', treePath);
41
+ }
42
+
43
+ const { exitCode, output } = await runGitCommand(lsTreeArgs, gitPath);
44
+
45
+ if (exitCode !== 0) {
46
+ if (output.includes('Not a valid object name') || output.includes('does not exist')) {
47
+ return notFound(c, `Reference not found: ${ref}`, { output });
48
+ }
49
+ return internalError(c, 'Failed to list tree', { output });
50
+ }
51
+
52
+ const entries = output
53
+ .split('\n')
54
+ .filter((line) => line.trim().length > 0)
55
+ .map((line) => {
56
+ const tabIndex = line.indexOf('\t');
57
+ if (tabIndex === -1) return null;
58
+
59
+ const metaParts = line.slice(0, tabIndex).split(/\s+/);
60
+ if (metaParts.length < 4) return null;
61
+
62
+ const [mode, type, hash, sizeStr] = metaParts;
63
+ const name = line.slice(tabIndex + 1);
64
+ const parsedSize = type === 'blob' ? parseInt(sizeStr, 10) : undefined;
65
+ const size = parsedSize !== undefined && Number.isFinite(parsedSize) ? parsedSize : undefined;
66
+
67
+ return {
68
+ mode,
69
+ type: type as 'blob' | 'tree',
70
+ hash,
71
+ size,
72
+ name,
73
+ path: treePath ? `${treePath}/${name}` : name,
74
+ };
75
+ })
76
+ .filter((entry) => entry !== null);
77
+
78
+ return c.json({
79
+ success: true,
80
+ ref,
81
+ path: treePath || '/',
82
+ entries,
83
+ });
84
+ } catch (err) {
85
+ return internalError(c, getErrorMessage(err));
86
+ }
87
+ });
88
+
89
+ app.get('/repos/:spaceId/:repoName/blob', async (c) => {
90
+ try {
91
+ const spaceId = c.req.param('spaceId');
92
+ const repoName = c.req.param('repoName');
93
+ const ref = c.req.query('ref') || 'HEAD';
94
+ const filePath = c.req.query('path');
95
+
96
+ const paramsErr = requireRepoParams(c, spaceId, repoName);
97
+ if (paramsErr) return paramsErr;
98
+
99
+ if (!filePath) {
100
+ return badRequest(c, 'path query parameter is required');
101
+ }
102
+
103
+ const refErr = validateRef(c, ref);
104
+ if (refErr) return refErr;
105
+ const pathErr = validatePathParam(c, filePath);
106
+ if (pathErr) return pathErr;
107
+
108
+ const repoResult = await getVerifiedRepoPath(c, spaceId, repoName);
109
+ if ('error' in repoResult) return repoResult.error;
110
+ const gitPath = repoResult.gitPath;
111
+
112
+ const { exitCode, output } = await runGitCommand(['show', `${ref}:${filePath}`], gitPath);
113
+
114
+ if (exitCode !== 0) {
115
+ if (output.includes('does not exist') || output.includes('Not a valid object')) {
116
+ return notFound(c, `File not found: ${filePath} at ${ref}`, { output });
117
+ }
118
+ return internalError(c, 'Failed to get file content', { output });
119
+ }
120
+
121
+ const lsResult = await runGitCommand(['ls-tree', '-l', ref, '--', filePath], gitPath);
122
+
123
+ let size: number | undefined;
124
+ let mode: string | undefined;
125
+ if (lsResult.exitCode === 0 && lsResult.output.trim()) {
126
+ const parts = lsResult.output.trim().split(/\s+/);
127
+ if (parts.length >= 4) {
128
+ mode = parts[0];
129
+ const rawSize = parseInt(parts[3], 10);
130
+ size = Number.isFinite(rawSize) ? rawSize : undefined;
131
+ }
132
+ }
133
+
134
+ return c.json({
135
+ success: true,
136
+ ref,
137
+ path: filePath,
138
+ content: output,
139
+ size,
140
+ mode,
141
+ });
142
+ } catch (err) {
143
+ return internalError(c, getErrorMessage(err));
144
+ }
145
+ });
146
+
147
+ // ---------------------------------------------------------------------------
148
+ // history: commit log
149
+ // ---------------------------------------------------------------------------
150
+
151
+ app.get('/repos/:spaceId/:repoName/commits', async (c) => {
152
+ try {
153
+ const spaceId = c.req.param('spaceId');
154
+ const repoName = c.req.param('repoName');
155
+ const limit = c.req.query('limit') || '20';
156
+ const branch = c.req.query('branch') || 'HEAD';
157
+
158
+ const paramsErr = requireRepoParams(c, spaceId, repoName);
159
+ if (paramsErr) return paramsErr;
160
+
161
+ const repoResult = await getVerifiedRepoPath(c, spaceId, repoName);
162
+ if ('error' in repoResult) return repoResult.error;
163
+ const gitPath = repoResult.gitPath;
164
+
165
+ const refErr = validateRef(c, branch);
166
+ if (refErr) return refErr;
167
+
168
+ const limitNum = Math.min(Math.max(parseInt(limit, 10) || 20, 1), 100);
169
+ const { exitCode, output } = await runGitCommand(
170
+ ['log', branch, `-n${limitNum}`, '--format=%H|%s|%an|%ae|%aI'],
171
+ gitPath
172
+ );
173
+
174
+ if (exitCode !== 0) {
175
+ if (output.includes('unknown revision') || output.includes('does not have any commits')) {
176
+ return c.json({
177
+ success: true,
178
+ commits: [],
179
+ });
180
+ }
181
+ return internalError(c, 'Failed to get commit history', { output });
182
+ }
183
+
184
+ const commits = output
185
+ .split('\n')
186
+ .filter((line) => line.trim().length > 0)
187
+ .map((line) => {
188
+ const [hash, message, authorName, authorEmail, date] = line.split('|');
189
+ return {
190
+ hash,
191
+ message,
192
+ author: {
193
+ name: authorName,
194
+ email: authorEmail,
195
+ },
196
+ date,
197
+ };
198
+ });
199
+
200
+ return c.json({
201
+ success: true,
202
+ commits,
203
+ });
204
+ } catch (err) {
205
+ return internalError(c, getErrorMessage(err));
206
+ }
207
+ });
208
+
209
+ export default app;
@@ -0,0 +1,130 @@
1
+ import { Hono } from 'hono';
2
+ import * as fs from 'fs/promises';
3
+ import * as path from 'path';
4
+ import { REPOS_BASE_DIR, WORKDIR_BASE_DIR } from '../../shared/config.js';
5
+ import { runGitCommand } from '../../runtime/git.js';
6
+ import {
7
+ getRepoPath,
8
+ verifyNoSymlinkPathComponents,
9
+ verifyPathWithinAfterAccess,
10
+ } from '../../runtime/paths.js';
11
+ import { getErrorMessage } from 'takos-common/errors';
12
+ import {
13
+ validateRef,
14
+ validateTargetDir,
15
+ } from './repo-validation.js';
16
+ import { isBoundaryViolationError } from '../../shared/errors.js';
17
+ import { badRequest, forbidden, internalError, notFound } from 'takos-common/middleware/hono';
18
+ import { ErrorCodes } from 'takos-common/errors';
19
+ import branchRoutes from './branches.js';
20
+ import contentRoutes from './content.js';
21
+
22
+ const app = new Hono();
23
+
24
+ // ---------------------------------------------------------------------------
25
+ // base: init + clone
26
+ // ---------------------------------------------------------------------------
27
+
28
+ app.post('/repos/init', async (c) => {
29
+ try {
30
+ const { spaceId, repoName = 'main' } = await c.req.json() as {
31
+ spaceId: string;
32
+ repoName?: string;
33
+ };
34
+
35
+ if (!spaceId) {
36
+ return badRequest(c, 'spaceId is required');
37
+ }
38
+
39
+ const gitPath = getRepoPath(spaceId, repoName);
40
+
41
+ try {
42
+ await fs.access(gitPath);
43
+ return c.json({ error: { code: ErrorCodes.CONFLICT, message: 'Repository already exists', details: { gitPath } } }, 409);
44
+ } catch {
45
+ // Repository does not exist yet - proceed with creation.
46
+ }
47
+
48
+ await fs.mkdir(path.dirname(gitPath), { recursive: true });
49
+
50
+ const { exitCode, output } = await runGitCommand(['init', '--bare', gitPath], REPOS_BASE_DIR);
51
+
52
+ if (exitCode !== 0) {
53
+ return internalError(c, 'Failed to initialize repository', { output });
54
+ }
55
+
56
+ return c.json({
57
+ success: true,
58
+ gitPath,
59
+ message: 'Repository initialized successfully',
60
+ });
61
+ } catch (err) {
62
+ return internalError(c, getErrorMessage(err));
63
+ }
64
+ });
65
+
66
+ app.post('/repos/clone', async (c) => {
67
+ try {
68
+ const { spaceId, repoName, branch, targetDir } = await c.req.json() as {
69
+ spaceId: string;
70
+ repoName: string;
71
+ branch?: string;
72
+ targetDir: string;
73
+ };
74
+
75
+ if (!spaceId || !repoName || !targetDir) {
76
+ return badRequest(c, 'spaceId, repoName, and targetDir are required');
77
+ }
78
+
79
+ const targetDirResult = await validateTargetDir(c, targetDir);
80
+ if ('error' in targetDirResult) return targetDirResult.error;
81
+ const resolvedTargetDir = targetDirResult.resolved;
82
+
83
+ const gitPath = getRepoPath(spaceId, repoName);
84
+
85
+ try {
86
+ await fs.access(gitPath);
87
+ } catch {
88
+ return notFound(c, 'Repository not found', { gitPath });
89
+ }
90
+
91
+ const targetParentDir = path.dirname(resolvedTargetDir);
92
+ await fs.mkdir(targetParentDir, { recursive: true });
93
+ await verifyNoSymlinkPathComponents(WORKDIR_BASE_DIR, resolvedTargetDir, 'targetDir');
94
+ await verifyPathWithinAfterAccess(WORKDIR_BASE_DIR, targetParentDir, 'targetDir');
95
+
96
+ const cloneArgs = ['clone'];
97
+ if (branch) {
98
+ const refErr = validateRef(c, branch);
99
+ if (refErr) return refErr;
100
+ cloneArgs.push('--branch', branch);
101
+ }
102
+ cloneArgs.push(gitPath, resolvedTargetDir);
103
+
104
+ const { exitCode, output } = await runGitCommand(cloneArgs, '/');
105
+
106
+ if (exitCode !== 0) {
107
+ return internalError(c, 'Failed to clone repository', { output });
108
+ }
109
+
110
+ await verifyPathWithinAfterAccess(WORKDIR_BASE_DIR, resolvedTargetDir, 'targetDir');
111
+
112
+ return c.json({
113
+ success: true,
114
+ targetDir: resolvedTargetDir,
115
+ branch: branch || 'default',
116
+ message: 'Repository cloned successfully',
117
+ });
118
+ } catch (err) {
119
+ if (isBoundaryViolationError(err)) {
120
+ return forbidden(c, 'Path escapes workdir boundary');
121
+ }
122
+ return internalError(c, getErrorMessage(err));
123
+ }
124
+ });
125
+
126
+ // Mount branch and content sub-routes
127
+ app.route('/', branchRoutes);
128
+ app.route('/', contentRoutes);
129
+
130
+ export default app;
@@ -0,0 +1,136 @@
1
+ import type { Context } from 'hono';
2
+ import * as fs from 'fs/promises';
3
+ import * as path from 'path';
4
+ import { WORKDIR_BASE_DIR } from '../../shared/config.js';
5
+ import {
6
+ getRepoPath,
7
+ resolveWorkDirPath,
8
+ verifyNoSymlinkPathComponents,
9
+ verifyPathWithinBeforeCreate,
10
+ verifyPathWithinAfterAccess,
11
+ } from '../../runtime/paths.js';
12
+ import { validateGitRef, validateGitPath } from '../../runtime/validation.js';
13
+ import { getErrorMessage } from 'takos-common/errors';
14
+ import { isBoundaryViolationError } from '../../shared/errors.js';
15
+ import { badRequest, forbidden, notFound } from 'takos-common/middleware/hono';
16
+
17
+ // --- getVerifiedRepoPath ---
18
+
19
+ export async function getVerifiedRepoPath(
20
+ c: Context,
21
+ spaceId: string,
22
+ repoName: string,
23
+ ): Promise<{ gitPath: string } | { error: Response }> {
24
+ const gitPath = getRepoPath(spaceId, repoName);
25
+
26
+ try {
27
+ await fs.access(gitPath);
28
+ return { gitPath };
29
+ } catch {
30
+ return { error: notFound(c, 'Repository not found', { gitPath }) };
31
+ }
32
+ }
33
+
34
+ /**
35
+ * Validate that a git ref is well-formed.
36
+ * Returns null on success, or a Response on failure.
37
+ */
38
+ export function validateRef(c: Context, ref: string): Response | null {
39
+ try {
40
+ validateGitRef(ref);
41
+ return null;
42
+ } catch (err) {
43
+ return badRequest(c, getErrorMessage(err));
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Validate that a git path is well-formed.
49
+ * Returns null on success, or a Response on failure.
50
+ */
51
+ export function validatePathParam(c: Context, filePath: string): Response | null {
52
+ try {
53
+ validateGitPath(filePath);
54
+ return null;
55
+ } catch (err) {
56
+ return badRequest(c, getErrorMessage(err));
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Validate that spaceId and repoName are present.
62
+ * Returns null on success, or a Response on failure.
63
+ */
64
+ export function requireRepoParams(c: Context, spaceId: string | undefined, repoName: string | undefined): Response | null {
65
+ if (!spaceId || !repoName) {
66
+ return badRequest(c, 'spaceId and repoName are required');
67
+ }
68
+ return null;
69
+ }
70
+
71
+ /**
72
+ * Resolve and validate a target directory for repo operations (clone, init).
73
+ * Checks: path resolution, symlink safety, boundary containment before creation.
74
+ * Returns the resolved path on success, or an error Response.
75
+ */
76
+ export async function validateTargetDir(c: Context, targetDir: string): Promise<{ resolved: string } | { error: Response }> {
77
+ let resolved: string;
78
+ try {
79
+ resolved = resolveWorkDirPath(targetDir, 'targetDir');
80
+ } catch {
81
+ return { error: badRequest(c, 'Invalid targetDir') };
82
+ }
83
+
84
+ try {
85
+ await verifyNoSymlinkPathComponents(WORKDIR_BASE_DIR, resolved, 'targetDir');
86
+ await verifyPathWithinBeforeCreate(WORKDIR_BASE_DIR, resolved, 'targetDir');
87
+ } catch (err) {
88
+ if (isBoundaryViolationError(err)) {
89
+ return { error: forbidden(c, 'Path escapes workdir boundary') };
90
+ }
91
+ return { error: badRequest(c, 'Invalid targetDir') };
92
+ }
93
+
94
+ try {
95
+ const stats = await fs.lstat(resolved);
96
+ if (stats.isSymbolicLink()) {
97
+ return { error: forbidden(c, 'Path escapes workdir boundary') };
98
+ }
99
+ } catch (err) {
100
+ if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
101
+ }
102
+
103
+ return { resolved };
104
+ }
105
+
106
+ /**
107
+ * Resolve and validate a working directory for git operations (commit, push).
108
+ * Checks: path resolution, symlink safety, boundary containment, .git presence.
109
+ * Returns the resolved path on success, or an error Response.
110
+ */
111
+ export async function resolveAndValidateWorkDir(c: Context, workDir: string): Promise<{ resolved: string } | { error: Response }> {
112
+ let resolved: string;
113
+ try {
114
+ resolved = resolveWorkDirPath(workDir, 'workDir');
115
+ } catch (err) {
116
+ return { error: badRequest(c, getErrorMessage(err)) };
117
+ }
118
+
119
+ try {
120
+ await verifyNoSymlinkPathComponents(WORKDIR_BASE_DIR, resolved, 'workDir');
121
+ await verifyPathWithinAfterAccess(WORKDIR_BASE_DIR, resolved, 'workDir');
122
+ } catch (err) {
123
+ if (isBoundaryViolationError(err)) {
124
+ return { error: forbidden(c, 'Path escapes workdir boundary') };
125
+ }
126
+ throw err;
127
+ }
128
+
129
+ try {
130
+ await fs.access(path.join(resolved, '.git'));
131
+ } catch {
132
+ return { error: badRequest(c, 'Not a git repository', { workDir: resolved }) };
133
+ }
134
+
135
+ return { resolved };
136
+ }