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.
- package/package.json +29 -0
- package/src/__tests__/middleware/rate-limit.test.ts +33 -0
- package/src/__tests__/middleware/workspace-scope-extended.test.ts +163 -0
- package/src/__tests__/routes/actions-start-limits.test.ts +139 -0
- package/src/__tests__/routes/actions-step-warnings.test.ts +194 -0
- package/src/__tests__/routes/cli-proxy.test.ts +72 -0
- package/src/__tests__/routes/git-http.test.ts +218 -0
- package/src/__tests__/routes/git-lfs-policy.test.ts +112 -0
- package/src/__tests__/routes/sessions/store.test.ts +72 -0
- package/src/__tests__/routes/workspace-scope.test.ts +45 -0
- package/src/__tests__/runtime/action-registry.test.ts +208 -0
- package/src/__tests__/runtime/action-result-helpers.test.ts +129 -0
- package/src/__tests__/runtime/actions/executor.test.ts +131 -0
- package/src/__tests__/runtime/composite-expression.test.ts +294 -0
- package/src/__tests__/runtime/file-parsers.test.ts +129 -0
- package/src/__tests__/runtime/logging.test.ts +65 -0
- package/src/__tests__/runtime/paths.test.ts +236 -0
- package/src/__tests__/runtime/secrets.test.ts +247 -0
- package/src/__tests__/runtime/validation.test.ts +516 -0
- package/src/__tests__/setup.ts +126 -0
- package/src/__tests__/shared/errors.test.ts +117 -0
- package/src/__tests__/storage/r2.test.ts +106 -0
- package/src/__tests__/utils/audit-log.test.ts +163 -0
- package/src/__tests__/utils/error-message.test.ts +38 -0
- package/src/__tests__/utils/sandbox-env.test.ts +74 -0
- package/src/app.ts +245 -0
- package/src/index.ts +1 -0
- package/src/middleware/rate-limit.ts +91 -0
- package/src/middleware/space-scope.ts +95 -0
- package/src/routes/actions/action-types.ts +20 -0
- package/src/routes/actions/execution.ts +229 -0
- package/src/routes/actions/index.ts +17 -0
- package/src/routes/actions/job-lifecycle.ts +242 -0
- package/src/routes/actions/job-queries.ts +52 -0
- package/src/routes/cli/proxy.ts +105 -0
- package/src/routes/git/http.ts +565 -0
- package/src/routes/git/init.ts +88 -0
- package/src/routes/repos/branches.ts +160 -0
- package/src/routes/repos/content.ts +209 -0
- package/src/routes/repos/read.ts +130 -0
- package/src/routes/repos/repo-validation.ts +136 -0
- package/src/routes/repos/write.ts +274 -0
- package/src/routes/runtime/exec.ts +147 -0
- package/src/routes/runtime/tools.ts +113 -0
- package/src/routes/sessions/execution.ts +263 -0
- package/src/routes/sessions/files.ts +326 -0
- package/src/routes/sessions/session-routes.ts +241 -0
- package/src/routes/sessions/session-utils.ts +88 -0
- package/src/routes/sessions/snapshot.ts +208 -0
- package/src/routes/sessions/storage.ts +329 -0
- package/src/runtime/actions/action-registry.ts +450 -0
- package/src/runtime/actions/action-result-converter.ts +31 -0
- package/src/runtime/actions/builtin/artifacts.ts +292 -0
- package/src/runtime/actions/builtin/cache-operations.ts +358 -0
- package/src/runtime/actions/builtin/checkout.ts +58 -0
- package/src/runtime/actions/builtin/index.ts +5 -0
- package/src/runtime/actions/builtin/setup-node.ts +86 -0
- package/src/runtime/actions/builtin/tar-parser.ts +175 -0
- package/src/runtime/actions/composite-executor.ts +192 -0
- package/src/runtime/actions/composite-expression.ts +190 -0
- package/src/runtime/actions/executor.ts +578 -0
- package/src/runtime/actions/file-parsers.ts +51 -0
- package/src/runtime/actions/job-manager.ts +213 -0
- package/src/runtime/actions/process-spawner.ts +275 -0
- package/src/runtime/actions/secrets.ts +162 -0
- package/src/runtime/command.ts +120 -0
- package/src/runtime/exec-runner.ts +309 -0
- package/src/runtime/git-http-backend.ts +145 -0
- package/src/runtime/git.ts +98 -0
- package/src/runtime/heartbeat.ts +57 -0
- package/src/runtime/logging.ts +26 -0
- package/src/runtime/paths.ts +264 -0
- package/src/runtime/secure-fs.ts +82 -0
- package/src/runtime/tools/network.ts +161 -0
- package/src/runtime/tools/worker.ts +335 -0
- package/src/runtime/validation.ts +292 -0
- package/src/shared/config.ts +149 -0
- package/src/shared/errors.ts +65 -0
- package/src/shared/temp-id.ts +10 -0
- package/src/storage/r2.ts +287 -0
- package/src/types/hono.d.ts +23 -0
- package/src/utils/audit-log.ts +92 -0
- package/src/utils/process-kill.ts +18 -0
- package/src/utils/sandbox-env.ts +136 -0
- package/src/utils/temp-dir.ts +74 -0
|
@@ -0,0 +1,565 @@
|
|
|
1
|
+
import { Hono } from 'hono';
|
|
2
|
+
import type { Context } from 'hono';
|
|
3
|
+
import * as fs from 'fs';
|
|
4
|
+
import * as fsPromises from 'fs/promises';
|
|
5
|
+
import path from 'path';
|
|
6
|
+
import { Transform } from 'stream';
|
|
7
|
+
import { pipeline } from 'stream/promises';
|
|
8
|
+
import { Readable } from 'stream';
|
|
9
|
+
import { badRequest, internalError, notFound } from 'takos-common/middleware/hono';
|
|
10
|
+
import { REPOS_BASE_DIR } from '../../shared/config.js';
|
|
11
|
+
import { isPathWithinBase, verifyPathWithinAfterAccess } from '../../runtime/paths.js';
|
|
12
|
+
import { validateGitName } from '../../runtime/validation.js';
|
|
13
|
+
import { runGitHttpBackend } from '../../runtime/git-http-backend.js';
|
|
14
|
+
import { enforceSpaceScopeMiddleware } from '../../middleware/space-scope.js';
|
|
15
|
+
|
|
16
|
+
// --- LFS policy helpers ---
|
|
17
|
+
|
|
18
|
+
export const LFS_OID_PATTERN = /^[a-f0-9]{64}$/i;
|
|
19
|
+
export const LFS_CONTENT_TYPE = 'application/vnd.git-lfs+json';
|
|
20
|
+
export const MAX_LFS_UPLOAD_BYTES = 1024 * 1024 * 1024;
|
|
21
|
+
export const LFS_UPLOAD_TOO_LARGE_ERROR = 'LFS upload payload exceeds maximum size';
|
|
22
|
+
|
|
23
|
+
export interface LfsBatchObjectDescriptor {
|
|
24
|
+
oid: string;
|
|
25
|
+
size: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface ParsedLfsBatchRequest {
|
|
29
|
+
operation: 'upload' | 'download';
|
|
30
|
+
objects: LfsBatchObjectDescriptor[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface LfsBatchObjectResponse {
|
|
34
|
+
oid: string;
|
|
35
|
+
size: number;
|
|
36
|
+
actions?: {
|
|
37
|
+
upload?: {
|
|
38
|
+
href: string;
|
|
39
|
+
expires_in: number;
|
|
40
|
+
};
|
|
41
|
+
download?: {
|
|
42
|
+
href: string;
|
|
43
|
+
expires_in: number;
|
|
44
|
+
};
|
|
45
|
+
};
|
|
46
|
+
error?: {
|
|
47
|
+
code: number;
|
|
48
|
+
message: string;
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function normalizeLfsOid(oid: string | undefined): string | null {
|
|
53
|
+
if (typeof oid !== 'string' || !LFS_OID_PATTERN.test(oid)) {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
return oid.toLowerCase();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function parseLfsBatchRequest(body: unknown): ParsedLfsBatchRequest | null {
|
|
60
|
+
if (!body || typeof body !== 'object') {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const operation = (body as { operation?: unknown }).operation;
|
|
65
|
+
const objects = (body as { objects?: unknown }).objects;
|
|
66
|
+
|
|
67
|
+
if (
|
|
68
|
+
(operation !== 'upload' && operation !== 'download') ||
|
|
69
|
+
!Array.isArray(objects)
|
|
70
|
+
) {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const parsedObjects: LfsBatchObjectDescriptor[] = [];
|
|
75
|
+
for (const object of objects) {
|
|
76
|
+
if (!object || typeof object !== 'object') {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const oid = normalizeLfsOid((object as { oid?: unknown }).oid as string | undefined);
|
|
81
|
+
const size = (object as { size?: unknown }).size;
|
|
82
|
+
|
|
83
|
+
if (
|
|
84
|
+
!oid ||
|
|
85
|
+
typeof size !== 'number' ||
|
|
86
|
+
!Number.isFinite(size) ||
|
|
87
|
+
size < 0
|
|
88
|
+
) {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
parsedObjects.push({ oid, size });
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
operation,
|
|
97
|
+
objects: parsedObjects,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function getLfsObjectPath(repoGitDir: string, oid: string): string {
|
|
102
|
+
return path.resolve(
|
|
103
|
+
repoGitDir,
|
|
104
|
+
'lfs',
|
|
105
|
+
'objects',
|
|
106
|
+
oid.slice(0, 2),
|
|
107
|
+
oid.slice(2, 4),
|
|
108
|
+
oid
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function buildLfsBatchObjectResponse(params: {
|
|
113
|
+
operation: 'upload' | 'download';
|
|
114
|
+
oid: string;
|
|
115
|
+
size: number;
|
|
116
|
+
exists: boolean;
|
|
117
|
+
href: string;
|
|
118
|
+
}): LfsBatchObjectResponse {
|
|
119
|
+
const { operation, oid, size, exists, href } = params;
|
|
120
|
+
|
|
121
|
+
if (operation === 'upload') {
|
|
122
|
+
if (exists) {
|
|
123
|
+
return { oid, size };
|
|
124
|
+
}
|
|
125
|
+
return {
|
|
126
|
+
oid,
|
|
127
|
+
size,
|
|
128
|
+
actions: {
|
|
129
|
+
upload: {
|
|
130
|
+
href,
|
|
131
|
+
expires_in: 3600,
|
|
132
|
+
},
|
|
133
|
+
},
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (!exists) {
|
|
138
|
+
return {
|
|
139
|
+
oid,
|
|
140
|
+
size,
|
|
141
|
+
error: {
|
|
142
|
+
code: 404,
|
|
143
|
+
message: 'Object does not exist',
|
|
144
|
+
},
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return {
|
|
149
|
+
oid,
|
|
150
|
+
size,
|
|
151
|
+
actions: {
|
|
152
|
+
download: {
|
|
153
|
+
href,
|
|
154
|
+
expires_in: 3600,
|
|
155
|
+
},
|
|
156
|
+
},
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function parseContentLength(headerValue: string | undefined): number | null {
|
|
161
|
+
if (typeof headerValue !== 'string' || headerValue.trim().length === 0) {
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
if (!/^\d+$/.test(headerValue)) {
|
|
165
|
+
return NaN;
|
|
166
|
+
}
|
|
167
|
+
return Number.parseInt(headerValue, 10);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ---------------------------------------------------------------------------
|
|
171
|
+
// --- Validators ---
|
|
172
|
+
// ---------------------------------------------------------------------------
|
|
173
|
+
|
|
174
|
+
export interface ValidatedRepoParams {
|
|
175
|
+
spaceId: string;
|
|
176
|
+
repoName: string;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export type ResolvedRepoGitDir = ValidatedRepoParams & { repoGitDir: string };
|
|
180
|
+
|
|
181
|
+
export interface ValidatedLfsObjectRequest {
|
|
182
|
+
oid: string;
|
|
183
|
+
objectPath: string;
|
|
184
|
+
repo: ResolvedRepoGitDir;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export function validateRepoParams(c: Context): ValidatedRepoParams | { error: Response } {
|
|
188
|
+
const spaceId = c.req.param('spaceId') ?? c.req.param('workspaceId') ?? '';
|
|
189
|
+
const pathParts = c.req.path.split('/').filter(Boolean);
|
|
190
|
+
const repoSegment = c.req.param('repoName') ?? pathParts[2] ?? '';
|
|
191
|
+
const repoName = repoSegment.replace(/\.git$/i, '');
|
|
192
|
+
const safeSpaceId = validateGitName(spaceId);
|
|
193
|
+
const safeRepoName = validateGitName(repoName);
|
|
194
|
+
|
|
195
|
+
if (!safeSpaceId || !safeRepoName) {
|
|
196
|
+
return { error: badRequest(c, 'Invalid space or repository name') };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return {
|
|
200
|
+
spaceId: safeSpaceId,
|
|
201
|
+
repoName: safeRepoName,
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export async function resolveRepoGitDir(
|
|
206
|
+
c: Context
|
|
207
|
+
): Promise<ResolvedRepoGitDir | { error: Response }> {
|
|
208
|
+
const params = validateRepoParams(c);
|
|
209
|
+
if ('error' in params) return params;
|
|
210
|
+
|
|
211
|
+
const repoGitDir = path.resolve(REPOS_BASE_DIR, params.spaceId, `${params.repoName}.git`);
|
|
212
|
+
const resolvedBase = path.resolve(REPOS_BASE_DIR);
|
|
213
|
+
|
|
214
|
+
if (!isPathWithinBase(resolvedBase, repoGitDir)) {
|
|
215
|
+
return { error: badRequest(c, 'Invalid space or repository name') };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
try {
|
|
219
|
+
const stats = await fsPromises.stat(repoGitDir);
|
|
220
|
+
if (!stats.isDirectory()) {
|
|
221
|
+
return { error: notFound(c, 'Repository not found') };
|
|
222
|
+
}
|
|
223
|
+
} catch (err) {
|
|
224
|
+
const errCode = err instanceof Error && 'code' in err ? (err as NodeJS.ErrnoException).code : undefined;
|
|
225
|
+
if (errCode === 'ENOENT') {
|
|
226
|
+
return { error: notFound(c, 'Repository not found') };
|
|
227
|
+
}
|
|
228
|
+
throw err;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return {
|
|
232
|
+
...params,
|
|
233
|
+
repoGitDir,
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export function validateLfsObjectOid(c: Context): string | { error: Response } {
|
|
238
|
+
const normalizedOid = normalizeLfsOid(c.req.param('oid'));
|
|
239
|
+
if (!normalizedOid) {
|
|
240
|
+
return { error: badRequest(c, 'Invalid LFS object id') };
|
|
241
|
+
}
|
|
242
|
+
return normalizedOid;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
export async function validateLfsObjectRequest(
|
|
246
|
+
c: Context,
|
|
247
|
+
oid: string | null = null
|
|
248
|
+
): Promise<ValidatedLfsObjectRequest | { error: Response }> {
|
|
249
|
+
const normalizedOidResult = oid ?? validateLfsObjectOid(c);
|
|
250
|
+
if (typeof normalizedOidResult === 'object' && 'error' in normalizedOidResult) {
|
|
251
|
+
return normalizedOidResult;
|
|
252
|
+
}
|
|
253
|
+
const normalizedOid = typeof normalizedOidResult === 'string' ? normalizedOidResult : oid!;
|
|
254
|
+
|
|
255
|
+
const repo = await resolveRepoGitDir(c);
|
|
256
|
+
if ('error' in repo) {
|
|
257
|
+
return repo;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const objectPath = getLfsObjectPath(repo.repoGitDir, normalizedOid);
|
|
261
|
+
if (!isPathWithinBase(repo.repoGitDir, objectPath)) {
|
|
262
|
+
return { error: badRequest(c, 'Invalid LFS object path') };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
return {
|
|
266
|
+
oid: normalizedOid,
|
|
267
|
+
objectPath,
|
|
268
|
+
repo,
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// ---------------------------------------------------------------------------
|
|
273
|
+
// Route helpers
|
|
274
|
+
// ---------------------------------------------------------------------------
|
|
275
|
+
|
|
276
|
+
const app = new Hono();
|
|
277
|
+
|
|
278
|
+
const enforceSpaceScope = enforceSpaceScopeMiddleware((c) => [
|
|
279
|
+
c.req.param('spaceId'),
|
|
280
|
+
]);
|
|
281
|
+
|
|
282
|
+
function getLfsObjectHref(c: import('hono').Context, spaceId: string, repoName: string, oid: string): string {
|
|
283
|
+
const protocol = c.req.header('x-forwarded-proto') || 'http';
|
|
284
|
+
const host = c.req.header('host') || 'localhost';
|
|
285
|
+
return `${protocol}://${host}/git/${spaceId}/${repoName}.git/info/lfs/objects/${oid}`;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
async function fileExists(filePath: string): Promise<boolean> {
|
|
289
|
+
try {
|
|
290
|
+
await fsPromises.access(filePath, fs.constants.F_OK);
|
|
291
|
+
return true;
|
|
292
|
+
} catch (err) {
|
|
293
|
+
const errCode = err instanceof Error && 'code' in err ? (err as NodeJS.ErrnoException).code : undefined;
|
|
294
|
+
if (errCode === 'ENOENT') {
|
|
295
|
+
return false;
|
|
296
|
+
}
|
|
297
|
+
throw err;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Validate space/repo params and return safe git path suffix, or return error Response.
|
|
303
|
+
*/
|
|
304
|
+
function validateGitParams(
|
|
305
|
+
c: import('hono').Context,
|
|
306
|
+
suffix: string
|
|
307
|
+
): string | { error: Response } {
|
|
308
|
+
const params = validateRepoParams(c);
|
|
309
|
+
if ('error' in params) return params;
|
|
310
|
+
|
|
311
|
+
const { spaceId, repoName } = params;
|
|
312
|
+
return `/${spaceId}/${repoName}.git/${suffix}`;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function sendGitResult(
|
|
316
|
+
c: import('hono').Context,
|
|
317
|
+
result: { status: number; headers: Record<string, string>; body: Buffer }
|
|
318
|
+
): Response {
|
|
319
|
+
const headers = new Headers();
|
|
320
|
+
for (const [key, value] of Object.entries(result.headers)) {
|
|
321
|
+
headers.set(key, value);
|
|
322
|
+
}
|
|
323
|
+
const responseBody = Uint8Array.from(result.body);
|
|
324
|
+
return new Response(responseBody as unknown as BodyInit, {
|
|
325
|
+
status: result.status,
|
|
326
|
+
headers,
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
app.use('/git/:spaceId/:repoName.git/*', enforceSpaceScope);
|
|
331
|
+
|
|
332
|
+
app.post('/git/:spaceId/:repoName.git/info/lfs/objects/batch', async (c) => {
|
|
333
|
+
try {
|
|
334
|
+
const resolved = await resolveRepoGitDir(c);
|
|
335
|
+
if ('error' in resolved) return resolved.error;
|
|
336
|
+
|
|
337
|
+
const body = await c.req.json();
|
|
338
|
+
const parsedRequest = parseLfsBatchRequest(body);
|
|
339
|
+
if (!parsedRequest) {
|
|
340
|
+
return badRequest(c, 'Invalid LFS batch request');
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const { operation, objects: requestObjects } = parsedRequest;
|
|
344
|
+
const objects = await Promise.all(requestObjects.map(async ({ oid, size }) => {
|
|
345
|
+
const objectPath = getLfsObjectPath(resolved.repoGitDir, oid);
|
|
346
|
+
|
|
347
|
+
if (!isPathWithinBase(resolved.repoGitDir, objectPath)) {
|
|
348
|
+
return {
|
|
349
|
+
oid,
|
|
350
|
+
size,
|
|
351
|
+
error: {
|
|
352
|
+
code: 400,
|
|
353
|
+
message: 'Invalid object path',
|
|
354
|
+
},
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const exists = await fileExists(objectPath);
|
|
359
|
+
const href = getLfsObjectHref(c, resolved.spaceId, resolved.repoName, oid);
|
|
360
|
+
return buildLfsBatchObjectResponse({
|
|
361
|
+
operation,
|
|
362
|
+
oid,
|
|
363
|
+
size,
|
|
364
|
+
exists,
|
|
365
|
+
href,
|
|
366
|
+
});
|
|
367
|
+
}));
|
|
368
|
+
|
|
369
|
+
c.header('content-type', LFS_CONTENT_TYPE);
|
|
370
|
+
return c.json({
|
|
371
|
+
transfer: 'basic',
|
|
372
|
+
objects,
|
|
373
|
+
});
|
|
374
|
+
} catch (err) {
|
|
375
|
+
c.get('log')?.error('Git LFS batch error', { error: err as Error });
|
|
376
|
+
return internalError(c);
|
|
377
|
+
}
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
app.put('/git/:spaceId/:repoName.git/info/lfs/objects/:oid', async (c) => {
|
|
381
|
+
try {
|
|
382
|
+
const oidResult = validateLfsObjectOid(c);
|
|
383
|
+
if (typeof oidResult === 'object' && 'error' in oidResult) return oidResult.error;
|
|
384
|
+
const normalizedOid = oidResult as string;
|
|
385
|
+
|
|
386
|
+
const contentLength = parseContentLength(c.req.header('content-length'));
|
|
387
|
+
if (Number.isNaN(contentLength)) {
|
|
388
|
+
return badRequest(c, 'Invalid Content-Length');
|
|
389
|
+
}
|
|
390
|
+
if (typeof contentLength === 'number' && contentLength > MAX_LFS_UPLOAD_BYTES) {
|
|
391
|
+
return c.json({ error: { code: 'PAYLOAD_TOO_LARGE', message: 'LFS object too large' } }, 413);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
const validatedObject = await validateLfsObjectRequest(c, normalizedOid);
|
|
395
|
+
if ('error' in validatedObject) return validatedObject.error;
|
|
396
|
+
|
|
397
|
+
const { objectPath } = validatedObject;
|
|
398
|
+
|
|
399
|
+
if (await fileExists(objectPath)) {
|
|
400
|
+
return c.body(null, 200);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
await fsPromises.mkdir(path.dirname(objectPath), { recursive: true });
|
|
404
|
+
|
|
405
|
+
const tempPath = `${objectPath}.tmp-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
|
406
|
+
try {
|
|
407
|
+
let receivedBytes = 0;
|
|
408
|
+
const sizeLimiter = new Transform({
|
|
409
|
+
transform(chunk, _encoding, callback) {
|
|
410
|
+
receivedBytes += chunk.length;
|
|
411
|
+
if (receivedBytes > MAX_LFS_UPLOAD_BYTES) {
|
|
412
|
+
callback(new Error(LFS_UPLOAD_TOO_LARGE_ERROR));
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
callback(null, chunk);
|
|
416
|
+
},
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
// Convert the web ReadableStream to a Node.js Readable stream
|
|
420
|
+
const rawBody = c.req.raw.body;
|
|
421
|
+
if (!rawBody) {
|
|
422
|
+
return badRequest(c, 'Missing request body');
|
|
423
|
+
}
|
|
424
|
+
const nodeStream = Readable.fromWeb(rawBody as import('stream/web').ReadableStream);
|
|
425
|
+
|
|
426
|
+
await pipeline(nodeStream, sizeLimiter, fs.createWriteStream(tempPath, { flags: 'wx' }));
|
|
427
|
+
await fsPromises.rename(tempPath, objectPath);
|
|
428
|
+
|
|
429
|
+
// Security: after rename(), verify the final path still resolves within
|
|
430
|
+
// the repository directory (TOCTOU — a symlink could have been planted
|
|
431
|
+
// between the initial validation and the rename).
|
|
432
|
+
try {
|
|
433
|
+
await verifyPathWithinAfterAccess(REPOS_BASE_DIR, objectPath, 'LFS upload target');
|
|
434
|
+
} catch {
|
|
435
|
+
// The file escaped the repo tree — remove it and reject.
|
|
436
|
+
await fsPromises.rm(objectPath, { force: true }).catch(() => undefined /* cleanup: remove escaped file, best-effort */);
|
|
437
|
+
return badRequest(c, 'Invalid LFS object path');
|
|
438
|
+
}
|
|
439
|
+
} catch (err) {
|
|
440
|
+
await fsPromises.rm(tempPath, { force: true }).catch(() => undefined /* cleanup: remove temp file on upload error, best-effort */);
|
|
441
|
+
const errMessage = err instanceof Error ? err.message : undefined;
|
|
442
|
+
const errCode = err instanceof Error && 'code' in err ? (err as NodeJS.ErrnoException).code : undefined;
|
|
443
|
+
if (errMessage === LFS_UPLOAD_TOO_LARGE_ERROR) {
|
|
444
|
+
return c.json({ error: { code: 'PAYLOAD_TOO_LARGE', message: 'LFS object too large' } }, 413);
|
|
445
|
+
}
|
|
446
|
+
if (errCode === 'EEXIST') {
|
|
447
|
+
return c.body(null, 200);
|
|
448
|
+
}
|
|
449
|
+
throw err;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
return c.body(null, 200);
|
|
453
|
+
} catch (err) {
|
|
454
|
+
c.get('log')?.error('Git LFS upload error', { error: err as Error });
|
|
455
|
+
return internalError(c);
|
|
456
|
+
}
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
app.get('/git/:spaceId/:repoName.git/info/lfs/objects/:oid', async (c) => {
|
|
460
|
+
try {
|
|
461
|
+
const validatedObject = await validateLfsObjectRequest(c);
|
|
462
|
+
if ('error' in validatedObject) return validatedObject.error;
|
|
463
|
+
const { objectPath } = validatedObject;
|
|
464
|
+
|
|
465
|
+
let stats: fs.Stats;
|
|
466
|
+
try {
|
|
467
|
+
stats = await fsPromises.stat(objectPath);
|
|
468
|
+
if (!stats.isFile()) {
|
|
469
|
+
return notFound(c, 'LFS object not found');
|
|
470
|
+
}
|
|
471
|
+
} catch (err) {
|
|
472
|
+
const errCode = err instanceof Error && 'code' in err ? (err as NodeJS.ErrnoException).code : undefined;
|
|
473
|
+
if (errCode === 'ENOENT') {
|
|
474
|
+
return notFound(c, 'LFS object not found');
|
|
475
|
+
}
|
|
476
|
+
throw err;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// Security: after stat() resolves symlinks, verify the real path is still
|
|
480
|
+
// within the repository directory to prevent symlink-based path traversal.
|
|
481
|
+
try {
|
|
482
|
+
await verifyPathWithinAfterAccess(REPOS_BASE_DIR, objectPath, 'LFS object');
|
|
483
|
+
} catch {
|
|
484
|
+
return notFound(c, 'LFS object not found');
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// Read the file and return as binary response
|
|
488
|
+
const buffer = await fsPromises.readFile(objectPath);
|
|
489
|
+
return new Response(new Blob([buffer]), {
|
|
490
|
+
status: 200,
|
|
491
|
+
headers: {
|
|
492
|
+
'content-type': 'application/octet-stream',
|
|
493
|
+
'content-length': String(stats.size),
|
|
494
|
+
},
|
|
495
|
+
});
|
|
496
|
+
} catch (err) {
|
|
497
|
+
c.get('log')?.error('Git LFS download error', { error: err as Error });
|
|
498
|
+
return internalError(c);
|
|
499
|
+
}
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
app.get('/git/:spaceId/:repoName.git/info/refs', async (c) => {
|
|
503
|
+
try {
|
|
504
|
+
const service = c.req.query('service');
|
|
505
|
+
|
|
506
|
+
if (!service || !['git-upload-pack', 'git-receive-pack'].includes(service)) {
|
|
507
|
+
return c.text('Invalid service parameter', 400);
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
const gitPathResult = validateGitParams(c, 'info/refs');
|
|
511
|
+
if (typeof gitPathResult === 'object' && 'error' in gitPathResult) return gitPathResult.error;
|
|
512
|
+
const gitPath = gitPathResult as string;
|
|
513
|
+
|
|
514
|
+
return sendGitResult(c, await runGitHttpBackend({
|
|
515
|
+
projectRoot: REPOS_BASE_DIR,
|
|
516
|
+
gitPath,
|
|
517
|
+
service,
|
|
518
|
+
requestBody: null,
|
|
519
|
+
contentType: undefined,
|
|
520
|
+
}));
|
|
521
|
+
} catch (err) {
|
|
522
|
+
c.get('log')?.error('Git info/refs error', { error: err as Error });
|
|
523
|
+
return c.text('Internal server error', 500);
|
|
524
|
+
}
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
function createPackHandler(service: 'git-upload-pack' | 'git-receive-pack') {
|
|
528
|
+
return async (c: import('hono').Context) => {
|
|
529
|
+
try {
|
|
530
|
+
const gitPathResult = validateGitParams(c, service);
|
|
531
|
+
if (typeof gitPathResult === 'object' && 'error' in gitPathResult) return gitPathResult.error;
|
|
532
|
+
const gitPath = gitPathResult as string;
|
|
533
|
+
|
|
534
|
+
const rawBody = Buffer.from(await c.req.arrayBuffer());
|
|
535
|
+
|
|
536
|
+
return sendGitResult(
|
|
537
|
+
c,
|
|
538
|
+
await runGitHttpBackend({
|
|
539
|
+
projectRoot: REPOS_BASE_DIR,
|
|
540
|
+
gitPath,
|
|
541
|
+
service,
|
|
542
|
+
requestBody: rawBody,
|
|
543
|
+
contentType: c.req.header('content-type'),
|
|
544
|
+
})
|
|
545
|
+
);
|
|
546
|
+
} catch (err) {
|
|
547
|
+
c.get('log')?.error(`Git ${service} error`, { error: err as Error });
|
|
548
|
+
return c.text('Internal server error', 500);
|
|
549
|
+
}
|
|
550
|
+
};
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
app.post(
|
|
554
|
+
'/git/:spaceId/:repoName.git/git-upload-pack',
|
|
555
|
+
createPackHandler('git-upload-pack')
|
|
556
|
+
);
|
|
557
|
+
|
|
558
|
+
// receive-pack (push): limit aligned with Workers-side MAX_PUSH_PACKFILE_BYTES (90MB)
|
|
559
|
+
// plus overhead for pkt-line commands and headers
|
|
560
|
+
app.post(
|
|
561
|
+
'/git/:spaceId/:repoName.git/git-receive-pack',
|
|
562
|
+
createPackHandler('git-receive-pack')
|
|
563
|
+
);
|
|
564
|
+
|
|
565
|
+
export default app;
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { Hono } from 'hono';
|
|
2
|
+
import * as fs from 'fs/promises';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
import { badRequest, forbidden, internalError } from 'takos-common/middleware/hono';
|
|
5
|
+
import { getRepoPath } from '../../runtime/paths.js';
|
|
6
|
+
import { generateTempSuffix } from '../../shared/temp-id.js';
|
|
7
|
+
import { runGitCommand } from '../../runtime/git.js';
|
|
8
|
+
import { validateNameParam } from '../../runtime/validation.js';
|
|
9
|
+
import { hasSpaceScopeMismatch, SPACE_SCOPE_MISMATCH_ERROR } from '../../middleware/space-scope.js';
|
|
10
|
+
|
|
11
|
+
async function execGit(args: string[], cwd: string): Promise<void> {
|
|
12
|
+
const { exitCode, output } = await runGitCommand(args, cwd);
|
|
13
|
+
if (exitCode !== 0) {
|
|
14
|
+
throw new Error(`Git command exited with code ${exitCode}: ${output}`);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const app = new Hono();
|
|
19
|
+
|
|
20
|
+
app.post('/git/init', async (c) => {
|
|
21
|
+
try {
|
|
22
|
+
const { space_id, repo_name, git_path } = await c.req.json() as {
|
|
23
|
+
space_id: string;
|
|
24
|
+
repo_name: string;
|
|
25
|
+
git_path?: string;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const spaceError = validateNameParam(space_id, 'space_id');
|
|
29
|
+
if (spaceError) { return badRequest(c, spaceError); }
|
|
30
|
+
|
|
31
|
+
if (hasSpaceScopeMismatch(c, space_id)) {
|
|
32
|
+
return forbidden(c, SPACE_SCOPE_MISMATCH_ERROR);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const repoError = validateNameParam(repo_name, 'repo_name');
|
|
36
|
+
if (repoError) { return badRequest(c, repoError); }
|
|
37
|
+
|
|
38
|
+
let safeGitPath: string;
|
|
39
|
+
try {
|
|
40
|
+
safeGitPath = getRepoPath(space_id, repo_name);
|
|
41
|
+
} catch (error) {
|
|
42
|
+
return badRequest(c, error instanceof Error ? error.message : 'Invalid repository path');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (typeof git_path === 'string' && git_path.trim().length > 0) {
|
|
46
|
+
if (path.resolve(git_path) !== safeGitPath) {
|
|
47
|
+
return badRequest(c, 'git_path does not match expected repository path');
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
await fs.mkdir(path.dirname(safeGitPath), { recursive: true });
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
await fs.access(safeGitPath);
|
|
55
|
+
return c.json({ success: true, git_path: safeGitPath, message: 'Repository already exists', created: false });
|
|
56
|
+
} catch {
|
|
57
|
+
// Repository does not exist yet; continue to create it.
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
await execGit(['init', '--bare', safeGitPath], path.dirname(safeGitPath));
|
|
61
|
+
|
|
62
|
+
const tempDir = `/tmp/git-init-${generateTempSuffix()}`;
|
|
63
|
+
try {
|
|
64
|
+
await fs.mkdir(tempDir, { recursive: true });
|
|
65
|
+
await execGit(['clone', safeGitPath, tempDir], '/tmp');
|
|
66
|
+
|
|
67
|
+
const safeRepoName = repo_name.replace(/[^a-zA-Z0-9_\-\s]/g, '');
|
|
68
|
+
await fs.writeFile(path.join(tempDir, 'README.md'), `# ${safeRepoName}\n\nCreated by Takos Agent.\n`);
|
|
69
|
+
|
|
70
|
+
await execGit(['add', '.'], tempDir);
|
|
71
|
+
await execGit(
|
|
72
|
+
['-c', 'user.name=Takos', '-c', 'user.email=agent@takos.local', 'commit', '-m', 'Initial commit'],
|
|
73
|
+
tempDir
|
|
74
|
+
);
|
|
75
|
+
await execGit(['push', 'origin', 'main'], tempDir);
|
|
76
|
+
} finally {
|
|
77
|
+
await fs.rm(tempDir, { recursive: true, force: true });
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
c.get('log')?.info('Initialized bare git repository', { git_path: safeGitPath });
|
|
81
|
+
return c.json({ success: true, git_path: safeGitPath, message: 'Repository initialized successfully', created: true });
|
|
82
|
+
} catch (err) {
|
|
83
|
+
c.get('log')?.error('Failed to initialize git repository', { error: err as Error });
|
|
84
|
+
return internalError(c, 'Failed to initialize repository');
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
export default app;
|