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
package/package.json
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "takos-runtime-service",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"scripts": {
|
|
6
|
+
"build": "tsc -p tsconfig.json",
|
|
7
|
+
"test": "vitest run",
|
|
8
|
+
"typecheck": "tsc --noEmit"
|
|
9
|
+
},
|
|
10
|
+
"dependencies": {
|
|
11
|
+
"@aws-sdk/client-s3": "^3.700.0",
|
|
12
|
+
"@hono/node-server": "^1.13.0",
|
|
13
|
+
"takos-common": "workspace:*",
|
|
14
|
+
"hono": "^4.12.4",
|
|
15
|
+
"yaml": "^2.8.2"
|
|
16
|
+
},
|
|
17
|
+
"exports": {
|
|
18
|
+
".": "./src/index.ts",
|
|
19
|
+
"./package.json": "./package.json"
|
|
20
|
+
},
|
|
21
|
+
"files": [
|
|
22
|
+
"src",
|
|
23
|
+
"package.json"
|
|
24
|
+
],
|
|
25
|
+
"devDependencies": {
|
|
26
|
+
"typescript": "^5.7.0",
|
|
27
|
+
"vitest": "^2.1.0"
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { Hono } from 'hono';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import { createRateLimiter } from '../../middleware/rate-limit.js';
|
|
4
|
+
|
|
5
|
+
describe('createRateLimiter maxKeys saturation', () => {
|
|
6
|
+
it('fails closed when the key store is saturated', async () => {
|
|
7
|
+
const limiter = createRateLimiter({
|
|
8
|
+
maxRequests: 5,
|
|
9
|
+
windowMs: 60_000,
|
|
10
|
+
maxKeys: 1,
|
|
11
|
+
keyFn: (c) => c.req.header('x-rate-key') || 'unknown',
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
const app = new Hono();
|
|
15
|
+
app.use(limiter);
|
|
16
|
+
app.get('/', (c) => c.json({ ok: true }));
|
|
17
|
+
|
|
18
|
+
const first = await app.request('/', {
|
|
19
|
+
headers: { 'x-rate-key': 'key-1' },
|
|
20
|
+
});
|
|
21
|
+
expect(first.status).toBe(200);
|
|
22
|
+
|
|
23
|
+
const second = await app.request('/', {
|
|
24
|
+
headers: { 'x-rate-key': 'key-2' },
|
|
25
|
+
});
|
|
26
|
+
expect(second.status).toBe(429);
|
|
27
|
+
expect(await second.json()).toEqual({
|
|
28
|
+
error: 'Rate limiter capacity reached. Please try again later.',
|
|
29
|
+
retry_after_seconds: 60,
|
|
30
|
+
});
|
|
31
|
+
expect(second.headers.get('retry-after')).toBe('60');
|
|
32
|
+
});
|
|
33
|
+
});
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
getSpaceIdFromPath,
|
|
4
|
+
collectRequestedSpaceIds,
|
|
5
|
+
getScopedSpaceId,
|
|
6
|
+
hasSpaceScopeMismatch,
|
|
7
|
+
hasAnySpaceScopeMismatch,
|
|
8
|
+
SPACE_SCOPE_MISMATCH_ERROR,
|
|
9
|
+
} from '../../middleware/space-scope.js';
|
|
10
|
+
|
|
11
|
+
function createContext(overrides: {
|
|
12
|
+
path?: string;
|
|
13
|
+
serviceToken?: { scope_space_id?: string } | null;
|
|
14
|
+
parsedBody?: Record<string, unknown>;
|
|
15
|
+
} = {}) {
|
|
16
|
+
const { path = '/repos/ws1/myrepo', serviceToken = null, parsedBody } = overrides;
|
|
17
|
+
return {
|
|
18
|
+
req: {
|
|
19
|
+
path,
|
|
20
|
+
header: () => undefined,
|
|
21
|
+
},
|
|
22
|
+
get(key: string) {
|
|
23
|
+
if (key === 'serviceToken') return serviceToken;
|
|
24
|
+
if (key === 'parsedBody') return parsedBody;
|
|
25
|
+
return undefined;
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
// getSpaceIdFromPath
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
describe('getSpaceIdFromPath', () => {
|
|
35
|
+
it('extracts workspace ID from /repos/:spaceId/:repo path', () => {
|
|
36
|
+
const c = createContext({ path: '/repos/ws1/myrepo' });
|
|
37
|
+
expect(getSpaceIdFromPath(c as any)).toBe('ws1');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('extracts workspace ID from deeper paths', () => {
|
|
41
|
+
const c = createContext({ path: '/repos/ws1/myrepo/branches' });
|
|
42
|
+
expect(getSpaceIdFromPath(c as any)).toBe('ws1');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('returns null for non-repos path', () => {
|
|
46
|
+
const c = createContext({ path: '/api/health' });
|
|
47
|
+
expect(getSpaceIdFromPath(c as any)).toBeNull();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('returns null for too-short repos path', () => {
|
|
51
|
+
const c = createContext({ path: '/repos/ws1' });
|
|
52
|
+
expect(getSpaceIdFromPath(c as any)).toBeNull();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('returns null for empty repos path', () => {
|
|
56
|
+
const c = createContext({ path: '/repos' });
|
|
57
|
+
expect(getSpaceIdFromPath(c as any)).toBeNull();
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
// collectRequestedSpaceIds
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
describe('collectRequestedSpaceIds', () => {
|
|
66
|
+
it('returns unique non-empty strings', () => {
|
|
67
|
+
expect(collectRequestedSpaceIds(['ws1', 'ws2', 'ws1'])).toEqual(['ws1', 'ws2']);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('filters out non-string values', () => {
|
|
71
|
+
expect(collectRequestedSpaceIds([null, undefined, 123, 'ws1'])).toEqual(['ws1']);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('filters out empty strings', () => {
|
|
75
|
+
expect(collectRequestedSpaceIds(['', 'ws1', ''])).toEqual(['ws1']);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('returns empty array for all-invalid input', () => {
|
|
79
|
+
expect(collectRequestedSpaceIds([null, undefined, '', 0])).toEqual([]);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
// getScopedSpaceId
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
describe('getScopedSpaceId', () => {
|
|
88
|
+
it('returns scope_space_id from service token', () => {
|
|
89
|
+
const c = createContext({ serviceToken: { scope_space_id: 'ws1' } });
|
|
90
|
+
expect(getScopedSpaceId(c as any)).toBe('ws1');
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('returns undefined when no service token', () => {
|
|
94
|
+
const c = createContext({ serviceToken: null });
|
|
95
|
+
expect(getScopedSpaceId(c as any)).toBeUndefined();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('returns undefined when scope_space_id is not a string', () => {
|
|
99
|
+
const c = createContext({ serviceToken: { scope_space_id: 123 } as any });
|
|
100
|
+
expect(getScopedSpaceId(c as any)).toBeUndefined();
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// ---------------------------------------------------------------------------
|
|
105
|
+
// hasSpaceScopeMismatch
|
|
106
|
+
// ---------------------------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
describe('hasSpaceScopeMismatch', () => {
|
|
109
|
+
it('returns false when no service token', () => {
|
|
110
|
+
const c = createContext({ serviceToken: null });
|
|
111
|
+
expect(hasSpaceScopeMismatch(c as any, 'ws1')).toBe(false);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('returns false when spaceId matches scope', () => {
|
|
115
|
+
const c = createContext({ serviceToken: { scope_space_id: 'ws1' } });
|
|
116
|
+
expect(hasSpaceScopeMismatch(c as any, 'ws1')).toBe(false);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('returns true when spaceId does not match scope', () => {
|
|
120
|
+
const c = createContext({ serviceToken: { scope_space_id: 'ws1' } });
|
|
121
|
+
expect(hasSpaceScopeMismatch(c as any, 'ws2')).toBe(true);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('returns false when spaceId is empty/null/undefined', () => {
|
|
125
|
+
const c = createContext({ serviceToken: { scope_space_id: 'ws1' } });
|
|
126
|
+
expect(hasSpaceScopeMismatch(c as any, '')).toBe(false);
|
|
127
|
+
expect(hasSpaceScopeMismatch(c as any, null)).toBe(false);
|
|
128
|
+
expect(hasSpaceScopeMismatch(c as any, undefined)).toBe(false);
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
// ---------------------------------------------------------------------------
|
|
133
|
+
// hasAnySpaceScopeMismatch
|
|
134
|
+
// ---------------------------------------------------------------------------
|
|
135
|
+
|
|
136
|
+
describe('hasAnySpaceScopeMismatch', () => {
|
|
137
|
+
it('returns false when all match', () => {
|
|
138
|
+
const c = createContext({ serviceToken: { scope_space_id: 'ws1' } });
|
|
139
|
+
expect(hasAnySpaceScopeMismatch(c as any, ['ws1', 'ws1'])).toBe(false);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('returns true when any mismatch', () => {
|
|
143
|
+
const c = createContext({ serviceToken: { scope_space_id: 'ws1' } });
|
|
144
|
+
expect(hasAnySpaceScopeMismatch(c as any, ['ws1', 'ws2'])).toBe(true);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('returns false for empty array', () => {
|
|
148
|
+
const c = createContext({ serviceToken: { scope_space_id: 'ws1' } });
|
|
149
|
+
expect(hasAnySpaceScopeMismatch(c as any, [])).toBe(false);
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
// ---------------------------------------------------------------------------
|
|
154
|
+
// SPACE_SCOPE_MISMATCH_ERROR
|
|
155
|
+
// ---------------------------------------------------------------------------
|
|
156
|
+
|
|
157
|
+
describe('SPACE_SCOPE_MISMATCH_ERROR', () => {
|
|
158
|
+
it('is the expected string', () => {
|
|
159
|
+
expect(SPACE_SCOPE_MISMATCH_ERROR).toBe(
|
|
160
|
+
'Token workspace scope does not match requested workspace',
|
|
161
|
+
);
|
|
162
|
+
});
|
|
163
|
+
});
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { createTestApp, testRequest } from '../setup.js';
|
|
3
|
+
|
|
4
|
+
vi.hoisted(() => {
|
|
5
|
+
process.env.TAKOS_API_URL = 'https://takos.example.test';
|
|
6
|
+
});
|
|
7
|
+
|
|
8
|
+
import actionsRoutes from '../../routes/actions/index.js';
|
|
9
|
+
import { SANDBOX_LIMITS } from '../../shared/config.js';
|
|
10
|
+
|
|
11
|
+
function createStartBody(jobName: string, spaceId: string, stepCount = 1) {
|
|
12
|
+
return {
|
|
13
|
+
space_id: spaceId,
|
|
14
|
+
repoId: 'acme/repo',
|
|
15
|
+
ref: 'refs/heads/main',
|
|
16
|
+
sha: 'a'.repeat(40),
|
|
17
|
+
workflowPath: '.takos/workflows/ci.yml',
|
|
18
|
+
jobName,
|
|
19
|
+
steps: Array.from({ length: stepCount }, (_, i) => ({
|
|
20
|
+
name: `noop-${i + 1}`,
|
|
21
|
+
run: 'echo hello',
|
|
22
|
+
})),
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
describe('actions start step limits', () => {
|
|
27
|
+
it('rejects start when step count exceeds maxStepsPerJob', async () => {
|
|
28
|
+
const app = createTestApp();
|
|
29
|
+
app.route('/', actionsRoutes);
|
|
30
|
+
|
|
31
|
+
const jobId = `steps-over-limit-${Date.now()}`;
|
|
32
|
+
const response = await testRequest(app, {
|
|
33
|
+
method: 'POST',
|
|
34
|
+
path: `/actions/jobs/${jobId}/start`,
|
|
35
|
+
body: createStartBody(
|
|
36
|
+
'over-limit',
|
|
37
|
+
'workspace-step-limit',
|
|
38
|
+
SANDBOX_LIMITS.maxStepsPerJob + 1,
|
|
39
|
+
),
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
expect(response.status).toBe(400);
|
|
43
|
+
expect(response.body).toEqual({
|
|
44
|
+
error: {
|
|
45
|
+
code: 'BAD_REQUEST',
|
|
46
|
+
message: `Steps exceed per-job limit (max ${SANDBOX_LIMITS.maxStepsPerJob})`,
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('accepts start when step count equals maxStepsPerJob', async () => {
|
|
52
|
+
const app = createTestApp();
|
|
53
|
+
app.route('/', actionsRoutes);
|
|
54
|
+
|
|
55
|
+
const jobId = `steps-at-limit-${Date.now()}`;
|
|
56
|
+
|
|
57
|
+
try {
|
|
58
|
+
const response = await testRequest(app, {
|
|
59
|
+
method: 'POST',
|
|
60
|
+
path: `/actions/jobs/${jobId}/start`,
|
|
61
|
+
body: createStartBody(
|
|
62
|
+
'at-limit',
|
|
63
|
+
'workspace-step-limit',
|
|
64
|
+
SANDBOX_LIMITS.maxStepsPerJob,
|
|
65
|
+
),
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
expect(response.status).toBe(200);
|
|
69
|
+
expect(response.body).toMatchObject({
|
|
70
|
+
jobId,
|
|
71
|
+
status: 'running',
|
|
72
|
+
message: 'Job started successfully',
|
|
73
|
+
});
|
|
74
|
+
} finally {
|
|
75
|
+
await testRequest(app, {
|
|
76
|
+
method: 'DELETE',
|
|
77
|
+
path: `/actions/jobs/${jobId}`,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe('actions start concurrency limits', () => {
|
|
84
|
+
it('applies maxConcurrentJobs per workspace', async () => {
|
|
85
|
+
const app = createTestApp();
|
|
86
|
+
app.route('/', actionsRoutes);
|
|
87
|
+
|
|
88
|
+
const startedJobIds: string[] = [];
|
|
89
|
+
const prefix = `concurrency-${Date.now()}`;
|
|
90
|
+
const workspaceA = 'workspace-a';
|
|
91
|
+
const workspaceB = 'workspace-b';
|
|
92
|
+
|
|
93
|
+
try {
|
|
94
|
+
for (let i = 0; i < SANDBOX_LIMITS.maxConcurrentJobs; i++) {
|
|
95
|
+
const jobId = `${prefix}-${i}`;
|
|
96
|
+
const response = await testRequest(app, {
|
|
97
|
+
method: 'POST',
|
|
98
|
+
path: `/actions/jobs/${jobId}/start`,
|
|
99
|
+
body: createStartBody(`job-${i}`, workspaceA),
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
expect(response.status).toBe(200);
|
|
103
|
+
startedJobIds.push(jobId);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const otherWorkspaceJobId = `${prefix}-other-workspace`;
|
|
107
|
+
const otherWorkspaceResponse = await testRequest(app, {
|
|
108
|
+
method: 'POST',
|
|
109
|
+
path: `/actions/jobs/${otherWorkspaceJobId}/start`,
|
|
110
|
+
body: createStartBody('job-other-workspace', workspaceB),
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
expect(otherWorkspaceResponse.status).toBe(200);
|
|
114
|
+
startedJobIds.push(otherWorkspaceJobId);
|
|
115
|
+
|
|
116
|
+
const overflowJobId = `${prefix}-overflow`;
|
|
117
|
+
const overflowResponse = await testRequest(app, {
|
|
118
|
+
method: 'POST',
|
|
119
|
+
path: `/actions/jobs/${overflowJobId}/start`,
|
|
120
|
+
body: createStartBody('overflow-job', workspaceA),
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
expect(overflowResponse.status).toBe(429);
|
|
124
|
+
expect(overflowResponse.body).toEqual({
|
|
125
|
+
error: {
|
|
126
|
+
code: 'RATE_LIMITED',
|
|
127
|
+
message: `Concurrent job limit reached (max ${SANDBOX_LIMITS.maxConcurrentJobs})`,
|
|
128
|
+
},
|
|
129
|
+
});
|
|
130
|
+
} finally {
|
|
131
|
+
for (const jobId of startedJobIds) {
|
|
132
|
+
await testRequest(app, {
|
|
133
|
+
method: 'DELETE',
|
|
134
|
+
path: `/actions/jobs/${jobId}`,
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
});
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { createTestApp, testRequest } from '../setup.js';
|
|
3
|
+
|
|
4
|
+
vi.hoisted(() => {
|
|
5
|
+
process.env.TAKOS_API_URL = 'https://takos.example.test';
|
|
6
|
+
});
|
|
7
|
+
|
|
8
|
+
const { executeRunMock, executeActionMock, capturedStepEnvs } = vi.hoisted(() => ({
|
|
9
|
+
executeRunMock: vi.fn(),
|
|
10
|
+
executeActionMock: vi.fn(),
|
|
11
|
+
capturedStepEnvs: [] as Array<Record<string, string>>,
|
|
12
|
+
}));
|
|
13
|
+
|
|
14
|
+
vi.mock('../../runtime/actions/executor.js', () => ({
|
|
15
|
+
StepExecutor: class {
|
|
16
|
+
constructor(_workspacePath: string, env: Record<string, string>) {
|
|
17
|
+
capturedStepEnvs.push(env);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
executeRun = executeRunMock;
|
|
21
|
+
|
|
22
|
+
executeAction = executeActionMock;
|
|
23
|
+
},
|
|
24
|
+
}));
|
|
25
|
+
|
|
26
|
+
import actionsRoutes from '../../routes/actions/index.js';
|
|
27
|
+
|
|
28
|
+
describe('actions step behavior', () => {
|
|
29
|
+
beforeEach(() => {
|
|
30
|
+
executeRunMock.mockResolvedValue({
|
|
31
|
+
exitCode: 0,
|
|
32
|
+
stdout: 'ok',
|
|
33
|
+
stderr: '',
|
|
34
|
+
outputs: {},
|
|
35
|
+
conclusion: 'success',
|
|
36
|
+
});
|
|
37
|
+
executeActionMock.mockResolvedValue({
|
|
38
|
+
exitCode: 0,
|
|
39
|
+
stdout: '',
|
|
40
|
+
stderr: '',
|
|
41
|
+
outputs: {},
|
|
42
|
+
conclusion: 'success',
|
|
43
|
+
});
|
|
44
|
+
capturedStepEnvs.length = 0;
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('keeps secrets masked for printenv-like commands without custom warnings field', async () => {
|
|
48
|
+
const app = createTestApp();
|
|
49
|
+
app.route('/', actionsRoutes);
|
|
50
|
+
|
|
51
|
+
executeRunMock.mockResolvedValue({
|
|
52
|
+
exitCode: 0,
|
|
53
|
+
stdout: 'token=s3cr3t',
|
|
54
|
+
stderr: '',
|
|
55
|
+
outputs: {},
|
|
56
|
+
conclusion: 'success',
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const jobId = `step-mask-${Date.now()}`;
|
|
60
|
+
|
|
61
|
+
try {
|
|
62
|
+
const startResponse = await testRequest(app, {
|
|
63
|
+
method: 'POST',
|
|
64
|
+
path: `/actions/jobs/${jobId}/start`,
|
|
65
|
+
body: {
|
|
66
|
+
space_id: 'workspace-step-mask',
|
|
67
|
+
repoId: 'acme/repo',
|
|
68
|
+
ref: 'refs/heads/main',
|
|
69
|
+
sha: 'a'.repeat(40),
|
|
70
|
+
workflowPath: '.takos/workflows/ci.yml',
|
|
71
|
+
jobName: 'mask-job',
|
|
72
|
+
secrets: {
|
|
73
|
+
TOKEN: 's3cr3t',
|
|
74
|
+
},
|
|
75
|
+
steps: [{ name: 'step-1', run: 'echo hello' }],
|
|
76
|
+
},
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
expect(startResponse.status).toBe(200);
|
|
80
|
+
|
|
81
|
+
const stepResponse = await testRequest(app, {
|
|
82
|
+
method: 'POST',
|
|
83
|
+
path: `/actions/jobs/${jobId}/step/0`,
|
|
84
|
+
body: {
|
|
85
|
+
name: 'step-1',
|
|
86
|
+
run: 'echo hello',
|
|
87
|
+
},
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
expect(stepResponse.status).toBe(200);
|
|
91
|
+
expect(stepResponse.body).toMatchObject({
|
|
92
|
+
conclusion: 'success',
|
|
93
|
+
stdout: 'token=***',
|
|
94
|
+
});
|
|
95
|
+
expect(stepResponse.body).not.toHaveProperty('warnings');
|
|
96
|
+
|
|
97
|
+
const logsResponse = await testRequest(app, {
|
|
98
|
+
method: 'GET',
|
|
99
|
+
path: `/actions/jobs/${jobId}/logs`,
|
|
100
|
+
});
|
|
101
|
+
const logsBody = logsResponse.body as { logs: string[] };
|
|
102
|
+
expect(logsResponse.status).toBe(200);
|
|
103
|
+
expect(logsBody.logs.some((line) => line.includes('token=***'))).toBe(true);
|
|
104
|
+
expect(logsBody.logs.some((line) => line.includes('s3cr3t'))).toBe(false);
|
|
105
|
+
} finally {
|
|
106
|
+
await testRequest(app, {
|
|
107
|
+
method: 'DELETE',
|
|
108
|
+
path: `/actions/jobs/${jobId}`,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('always sets GITHUB_RUN_ID to jobId', async () => {
|
|
114
|
+
const app = createTestApp();
|
|
115
|
+
app.route('/', actionsRoutes);
|
|
116
|
+
|
|
117
|
+
const jobId = `with-run-id-${Date.now()}`;
|
|
118
|
+
|
|
119
|
+
try {
|
|
120
|
+
const startResponse = await testRequest(app, {
|
|
121
|
+
method: 'POST',
|
|
122
|
+
path: `/actions/jobs/${jobId}/start`,
|
|
123
|
+
body: {
|
|
124
|
+
space_id: 'workspace-run-id',
|
|
125
|
+
repoId: 'acme/repo',
|
|
126
|
+
ref: 'refs/heads/main',
|
|
127
|
+
sha: 'a'.repeat(40),
|
|
128
|
+
workflowPath: '.takos/workflows/ci.yml',
|
|
129
|
+
jobName: 'run-id-job',
|
|
130
|
+
steps: [{ name: 'step-1', run: 'echo hello' }],
|
|
131
|
+
},
|
|
132
|
+
});
|
|
133
|
+
expect(startResponse.status).toBe(200);
|
|
134
|
+
|
|
135
|
+
const stepResponse = await testRequest(app, {
|
|
136
|
+
method: 'POST',
|
|
137
|
+
path: `/actions/jobs/${jobId}/step/0`,
|
|
138
|
+
body: {
|
|
139
|
+
name: 'step-1',
|
|
140
|
+
run: 'echo hello',
|
|
141
|
+
},
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
expect(stepResponse.status).toBe(200);
|
|
145
|
+
expect(capturedStepEnvs.at(-1)?.GITHUB_RUN_ID).toBe(jobId);
|
|
146
|
+
} finally {
|
|
147
|
+
await testRequest(app, {
|
|
148
|
+
method: 'DELETE',
|
|
149
|
+
path: `/actions/jobs/${jobId}`,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('falls back to jobId for GITHUB_RUN_ID when runId is omitted', async () => {
|
|
155
|
+
const app = createTestApp();
|
|
156
|
+
app.route('/', actionsRoutes);
|
|
157
|
+
|
|
158
|
+
const jobId = `fallback-run-id-${Date.now()}`;
|
|
159
|
+
|
|
160
|
+
try {
|
|
161
|
+
const startResponse = await testRequest(app, {
|
|
162
|
+
method: 'POST',
|
|
163
|
+
path: `/actions/jobs/${jobId}/start`,
|
|
164
|
+
body: {
|
|
165
|
+
space_id: 'workspace-run-id-fallback',
|
|
166
|
+
repoId: 'acme/repo',
|
|
167
|
+
ref: 'refs/heads/main',
|
|
168
|
+
sha: 'a'.repeat(40),
|
|
169
|
+
workflowPath: '.takos/workflows/ci.yml',
|
|
170
|
+
jobName: 'fallback-run-id-job',
|
|
171
|
+
steps: [{ name: 'step-1', run: 'echo hello' }],
|
|
172
|
+
},
|
|
173
|
+
});
|
|
174
|
+
expect(startResponse.status).toBe(200);
|
|
175
|
+
|
|
176
|
+
const stepResponse = await testRequest(app, {
|
|
177
|
+
method: 'POST',
|
|
178
|
+
path: `/actions/jobs/${jobId}/step/0`,
|
|
179
|
+
body: {
|
|
180
|
+
name: 'step-1',
|
|
181
|
+
run: 'echo hello',
|
|
182
|
+
},
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
expect(stepResponse.status).toBe(200);
|
|
186
|
+
expect(capturedStepEnvs.at(-1)?.GITHUB_RUN_ID).toBe(jobId);
|
|
187
|
+
} finally {
|
|
188
|
+
await testRequest(app, {
|
|
189
|
+
method: 'DELETE',
|
|
190
|
+
path: `/actions/jobs/${jobId}`,
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
});
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { createTestApp, testRequest } from '../setup.js';
|
|
3
|
+
|
|
4
|
+
const hoisted = vi.hoisted(() => {
|
|
5
|
+
process.env.TAKOS_API_URL = 'https://takos.example.test';
|
|
6
|
+
process.env.PROXY_BASE_URL = 'https://runtime-host.example.test';
|
|
7
|
+
return {
|
|
8
|
+
getSession: vi.fn(),
|
|
9
|
+
fetch: vi.fn(),
|
|
10
|
+
};
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
vi.mock('../../routes/sessions/storage.js', () => ({
|
|
14
|
+
sessionStore: {
|
|
15
|
+
getSession: hoisted.getSession,
|
|
16
|
+
},
|
|
17
|
+
}));
|
|
18
|
+
|
|
19
|
+
import cliProxyRoutes from '../../routes/cli/proxy.js';
|
|
20
|
+
|
|
21
|
+
describe('cli-proxy route', () => {
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
hoisted.getSession.mockReset();
|
|
24
|
+
hoisted.fetch.mockReset();
|
|
25
|
+
vi.stubGlobal('fetch', hoisted.fetch);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
afterEach(() => {
|
|
29
|
+
vi.unstubAllGlobals();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('forwards query parameters via PROXY_BASE_URL while validating the path only', async () => {
|
|
33
|
+
const app = createTestApp();
|
|
34
|
+
app.route('/', cliProxyRoutes);
|
|
35
|
+
|
|
36
|
+
hoisted.getSession.mockReturnValue({
|
|
37
|
+
id: 'a12345678901234b',
|
|
38
|
+
spaceId: 'space-a',
|
|
39
|
+
proxyToken: 'random-proxy-token-abc',
|
|
40
|
+
lastAccessedAt: 0,
|
|
41
|
+
});
|
|
42
|
+
hoisted.fetch.mockResolvedValue({
|
|
43
|
+
status: 200,
|
|
44
|
+
text: async () => JSON.stringify({ ok: true }),
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const response = await testRequest(app, {
|
|
48
|
+
method: 'GET',
|
|
49
|
+
path: '/cli-proxy/api/repos/repo-1/status',
|
|
50
|
+
query: {
|
|
51
|
+
ref: 'refs/heads/main',
|
|
52
|
+
verbose: '1',
|
|
53
|
+
},
|
|
54
|
+
headers: {
|
|
55
|
+
'X-Takos-Session-Id': 'a12345678901234b',
|
|
56
|
+
'X-Takos-Space-Id': 'space-a',
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
expect(response.status).toBe(200);
|
|
61
|
+
expect(response.body).toEqual({ ok: true });
|
|
62
|
+
|
|
63
|
+
expect(hoisted.fetch).toHaveBeenCalledTimes(1);
|
|
64
|
+
const forwardedUrl = hoisted.fetch.mock.calls[0]?.[0] as string;
|
|
65
|
+
const forwardedOptions = hoisted.fetch.mock.calls[0]?.[1] as { headers: Record<string, string>; method: string };
|
|
66
|
+
|
|
67
|
+
expect(forwardedUrl).toBe('https://runtime-host.example.test/forward/cli-proxy/api/repos/repo-1/status?ref=refs%2Fheads%2Fmain&verbose=1');
|
|
68
|
+
expect(forwardedOptions.method).toBe('GET');
|
|
69
|
+
expect(forwardedOptions.headers['X-Takos-Session-Id']).toBe('a12345678901234b');
|
|
70
|
+
expect(forwardedOptions.headers.Authorization).toBe('Bearer random-proxy-token-abc');
|
|
71
|
+
});
|
|
72
|
+
});
|