@axiom-lattice/microsandbox-service 0.0.1
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/.turbo/turbo-build.log +29 -0
- package/CHANGELOG.md +7 -0
- package/LICENSE +201 -0
- package/dist/chunk-2W4CTYPX.mjs +464 -0
- package/dist/chunk-2W4CTYPX.mjs.map +1 -0
- package/dist/index.d.mts +208 -0
- package/dist/index.d.ts +208 -0
- package/dist/index.js +496 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +11 -0
- package/dist/index.mjs.map +1 -0
- package/dist/server.d.mts +9 -0
- package/dist/server.d.ts +9 -0
- package/dist/server.js +566 -0
- package/dist/server.js.map +1 -0
- package/dist/server.mjs +82 -0
- package/dist/server.mjs.map +1 -0
- package/jest.config.js +17 -0
- package/package.json +46 -0
- package/src/__tests__/MicrosandboxRuntimeService.test.ts +251 -0
- package/src/__tests__/SandboxRegistry.test.ts +101 -0
- package/src/__tests__/app.test.ts +224 -0
- package/src/__tests__/index.test.ts +33 -0
- package/src/__tests__/server-cli.test.ts +26 -0
- package/src/__tests__/server.test.ts +25 -0
- package/src/app.ts +31 -0
- package/src/controllers/sandbox.ts +138 -0
- package/src/index.ts +3 -0
- package/src/lib/errors.ts +44 -0
- package/src/lib/http.ts +11 -0
- package/src/lib/server-cli.ts +59 -0
- package/src/routes/health.ts +6 -0
- package/src/routes/sandbox.ts +28 -0
- package/src/schemas/sandbox.ts +79 -0
- package/src/server.ts +43 -0
- package/src/services/MicrosandboxRuntimeService.ts +247 -0
- package/src/services/SandboxRegistry.ts +69 -0
- package/src/types/runtime-service.ts +23 -0
- package/tsconfig.json +22 -0
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { buildApp } from "../app";
|
|
2
|
+
import type { RuntimeService } from "../types/runtime-service";
|
|
3
|
+
|
|
4
|
+
function createRuntimeService(overrides: Partial<RuntimeService> = {}): RuntimeService {
|
|
5
|
+
return {
|
|
6
|
+
ensureSandbox: jest.fn(),
|
|
7
|
+
startSandbox: jest.fn(),
|
|
8
|
+
stopSandbox: jest.fn(),
|
|
9
|
+
killSandbox: jest.fn(),
|
|
10
|
+
deleteSandbox: jest.fn(),
|
|
11
|
+
getStatus: jest.fn(),
|
|
12
|
+
readFile: jest.fn(),
|
|
13
|
+
writeFile: jest.fn(),
|
|
14
|
+
listPath: jest.fn(),
|
|
15
|
+
findFiles: jest.fn(),
|
|
16
|
+
searchInFile: jest.fn(),
|
|
17
|
+
replaceInFile: jest.fn(),
|
|
18
|
+
uploadFile: jest.fn(),
|
|
19
|
+
downloadFile: jest.fn(),
|
|
20
|
+
execCommand: jest.fn(),
|
|
21
|
+
...overrides,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe("microsandbox-service routes", () => {
|
|
26
|
+
it("returns health status", async () => {
|
|
27
|
+
const app = buildApp({
|
|
28
|
+
runtimeService: createRuntimeService(),
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
const response = await app.inject({ method: "GET", url: "/health" });
|
|
32
|
+
|
|
33
|
+
expect(response.statusCode).toBe(200);
|
|
34
|
+
expect(response.json()).toEqual({ success: true, data: { status: "ok" } });
|
|
35
|
+
|
|
36
|
+
await app.close();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("passes create payload including volumes to the runtime service", async () => {
|
|
40
|
+
const ensureSandbox = jest.fn().mockResolvedValue({
|
|
41
|
+
name: "tenant-project",
|
|
42
|
+
status: "running",
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
const app = buildApp({
|
|
46
|
+
runtimeService: createRuntimeService({ ensureSandbox }),
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const response = await app.inject({
|
|
50
|
+
method: "PUT",
|
|
51
|
+
url: "/api/sandboxes/tenant-project",
|
|
52
|
+
payload: {
|
|
53
|
+
image: "python:3.11-slim",
|
|
54
|
+
cpus: 1,
|
|
55
|
+
memoryMib: 512,
|
|
56
|
+
env: { PYTHONDONTWRITEBYTECODE: "1" },
|
|
57
|
+
volumes: {
|
|
58
|
+
"/home/microsandbox/project": {
|
|
59
|
+
type: "named",
|
|
60
|
+
name: "tenant-project-vol",
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
expect(response.statusCode).toBe(200);
|
|
67
|
+
expect(ensureSandbox).toHaveBeenCalledWith("tenant-project", {
|
|
68
|
+
image: "python:3.11-slim",
|
|
69
|
+
cpus: 1,
|
|
70
|
+
memoryMib: 512,
|
|
71
|
+
env: { PYTHONDONTWRITEBYTECODE: "1" },
|
|
72
|
+
volumes: {
|
|
73
|
+
"/home/microsandbox/project": {
|
|
74
|
+
type: "named",
|
|
75
|
+
name: "tenant-project-vol",
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
await app.close();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("routes lifecycle, file, and shell requests to the runtime service", async () => {
|
|
84
|
+
const binary = Buffer.from([0, 255, 1]);
|
|
85
|
+
const runtimeService = createRuntimeService({
|
|
86
|
+
startSandbox: jest.fn().mockResolvedValue({ name: "tenant-project", status: "running" }),
|
|
87
|
+
stopSandbox: jest.fn().mockResolvedValue({ name: "tenant-project", status: "stopped" }),
|
|
88
|
+
killSandbox: jest.fn().mockResolvedValue({ name: "tenant-project", status: "unknown" }),
|
|
89
|
+
deleteSandbox: jest.fn().mockResolvedValue({ name: "tenant-project", status: "unknown" }),
|
|
90
|
+
getStatus: jest.fn().mockResolvedValue({ name: "tenant-project", status: "running" }),
|
|
91
|
+
readFile: jest.fn().mockResolvedValue({ path: "/tmp/test.txt", content: "hello" }),
|
|
92
|
+
writeFile: jest.fn().mockResolvedValue({ path: "/tmp/test.txt" }),
|
|
93
|
+
listPath: jest.fn().mockResolvedValue({ entries: [{ name: "test.txt", type: "file" }] }),
|
|
94
|
+
findFiles: jest.fn().mockResolvedValue({ files: ["/tmp/test.txt"] }),
|
|
95
|
+
searchInFile: jest.fn().mockResolvedValue({ matches: [{ line: 1, content: "hello" }] }),
|
|
96
|
+
replaceInFile: jest.fn().mockResolvedValue({ replaced: 1 }),
|
|
97
|
+
uploadFile: jest.fn().mockResolvedValue({ path: "/tmp/upload.txt" }),
|
|
98
|
+
downloadFile: jest.fn().mockResolvedValue({ path: "/tmp/download.txt", contentBase64: binary.toString("base64") }),
|
|
99
|
+
execCommand: jest.fn().mockResolvedValue({ stdout: "ok", stderr: "", exitCode: 0 }),
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
const app = buildApp({ runtimeService });
|
|
103
|
+
|
|
104
|
+
const startResponse = await app.inject({ method: "POST", url: "/api/sandboxes/tenant-project/start" });
|
|
105
|
+
const stopResponse = await app.inject({ method: "POST", url: "/api/sandboxes/tenant-project/stop" });
|
|
106
|
+
const killResponse = await app.inject({ method: "POST", url: "/api/sandboxes/tenant-project/kill" });
|
|
107
|
+
const deleteResponse = await app.inject({ method: "DELETE", url: "/api/sandboxes/tenant-project" });
|
|
108
|
+
const statusResponse = await app.inject({ method: "GET", url: "/api/sandboxes/tenant-project/status" });
|
|
109
|
+
const readResponse = await app.inject({
|
|
110
|
+
method: "POST",
|
|
111
|
+
url: "/api/files/read",
|
|
112
|
+
payload: { sandboxName: "tenant-project", path: "/tmp/test.txt" },
|
|
113
|
+
});
|
|
114
|
+
const writeResponse = await app.inject({
|
|
115
|
+
method: "POST",
|
|
116
|
+
url: "/api/files/write",
|
|
117
|
+
payload: { sandboxName: "tenant-project", path: "/tmp/test.txt", content: "hello" },
|
|
118
|
+
});
|
|
119
|
+
const listResponse = await app.inject({
|
|
120
|
+
method: "POST",
|
|
121
|
+
url: "/api/files/list",
|
|
122
|
+
payload: { sandboxName: "tenant-project", path: "/tmp", recursive: true },
|
|
123
|
+
});
|
|
124
|
+
const findResponse = await app.inject({
|
|
125
|
+
method: "POST",
|
|
126
|
+
url: "/api/files/find",
|
|
127
|
+
payload: { sandboxName: "tenant-project", path: "/tmp", pattern: "*.txt" },
|
|
128
|
+
});
|
|
129
|
+
const searchResponse = await app.inject({
|
|
130
|
+
method: "POST",
|
|
131
|
+
url: "/api/files/search",
|
|
132
|
+
payload: { sandboxName: "tenant-project", path: "/tmp/test.txt", query: "hello" },
|
|
133
|
+
});
|
|
134
|
+
const replaceResponse = await app.inject({
|
|
135
|
+
method: "POST",
|
|
136
|
+
url: "/api/files/replace",
|
|
137
|
+
payload: {
|
|
138
|
+
sandboxName: "tenant-project",
|
|
139
|
+
path: "/tmp/test.txt",
|
|
140
|
+
search: "hello",
|
|
141
|
+
replace: "world",
|
|
142
|
+
},
|
|
143
|
+
});
|
|
144
|
+
const uploadResponse = await app.inject({
|
|
145
|
+
method: "POST",
|
|
146
|
+
url: "/api/files/upload",
|
|
147
|
+
payload: {
|
|
148
|
+
sandboxName: "tenant-project",
|
|
149
|
+
path: "/tmp/upload.txt",
|
|
150
|
+
contentBase64: binary.toString("base64"),
|
|
151
|
+
},
|
|
152
|
+
});
|
|
153
|
+
const downloadResponse = await app.inject({
|
|
154
|
+
method: "GET",
|
|
155
|
+
url: "/api/files/download?sandboxName=tenant-project&path=%2Ftmp%2Fdownload.txt",
|
|
156
|
+
});
|
|
157
|
+
const execResponse = await app.inject({
|
|
158
|
+
method: "POST",
|
|
159
|
+
url: "/api/shell/exec",
|
|
160
|
+
payload: { sandboxName: "tenant-project", command: "pwd", exec_dir: "/tmp", timeout: 10 },
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
expect(startResponse.statusCode).toBe(200);
|
|
164
|
+
expect(stopResponse.statusCode).toBe(200);
|
|
165
|
+
expect(killResponse.statusCode).toBe(200);
|
|
166
|
+
expect(deleteResponse.statusCode).toBe(200);
|
|
167
|
+
expect(statusResponse.statusCode).toBe(200);
|
|
168
|
+
expect(readResponse.statusCode).toBe(200);
|
|
169
|
+
expect(writeResponse.statusCode).toBe(200);
|
|
170
|
+
expect(listResponse.statusCode).toBe(200);
|
|
171
|
+
expect(findResponse.statusCode).toBe(200);
|
|
172
|
+
expect(searchResponse.statusCode).toBe(200);
|
|
173
|
+
expect(replaceResponse.statusCode).toBe(200);
|
|
174
|
+
expect(uploadResponse.statusCode).toBe(200);
|
|
175
|
+
expect(downloadResponse.statusCode).toBe(200);
|
|
176
|
+
expect(execResponse.statusCode).toBe(200);
|
|
177
|
+
|
|
178
|
+
expect(runtimeService.startSandbox).toHaveBeenCalledWith("tenant-project");
|
|
179
|
+
expect(runtimeService.stopSandbox).toHaveBeenCalledWith("tenant-project");
|
|
180
|
+
expect(runtimeService.killSandbox).toHaveBeenCalledWith("tenant-project");
|
|
181
|
+
expect(runtimeService.deleteSandbox).toHaveBeenCalledWith("tenant-project");
|
|
182
|
+
expect(runtimeService.getStatus).toHaveBeenCalledWith("tenant-project");
|
|
183
|
+
expect(runtimeService.readFile).toHaveBeenCalledWith("tenant-project", "/tmp/test.txt");
|
|
184
|
+
expect(runtimeService.writeFile).toHaveBeenCalledWith("tenant-project", "/tmp/test.txt", "hello");
|
|
185
|
+
expect(runtimeService.listPath).toHaveBeenCalledWith("tenant-project", "/tmp", true);
|
|
186
|
+
expect(runtimeService.findFiles).toHaveBeenCalledWith("tenant-project", "/tmp", "*.txt");
|
|
187
|
+
expect(runtimeService.searchInFile).toHaveBeenCalledWith("tenant-project", "/tmp/test.txt", "hello");
|
|
188
|
+
expect(runtimeService.replaceInFile).toHaveBeenCalledWith("tenant-project", {
|
|
189
|
+
path: "/tmp/test.txt",
|
|
190
|
+
search: "hello",
|
|
191
|
+
replace: "world",
|
|
192
|
+
});
|
|
193
|
+
expect(runtimeService.uploadFile).toHaveBeenCalledWith(
|
|
194
|
+
"tenant-project",
|
|
195
|
+
"/tmp/upload.txt",
|
|
196
|
+
binary.toString("base64")
|
|
197
|
+
);
|
|
198
|
+
expect(runtimeService.downloadFile).toHaveBeenCalledWith("tenant-project", "/tmp/download.txt");
|
|
199
|
+
expect(runtimeService.execCommand).toHaveBeenCalledWith({
|
|
200
|
+
sandboxName: "tenant-project",
|
|
201
|
+
command: "pwd",
|
|
202
|
+
exec_dir: "/tmp",
|
|
203
|
+
timeout: 10,
|
|
204
|
+
});
|
|
205
|
+
expect(startResponse.json()).toEqual({
|
|
206
|
+
success: true,
|
|
207
|
+
data: { name: "tenant-project", status: "running" },
|
|
208
|
+
});
|
|
209
|
+
expect(readResponse.json()).toEqual({
|
|
210
|
+
success: true,
|
|
211
|
+
data: { path: "/tmp/test.txt", content: "hello" },
|
|
212
|
+
});
|
|
213
|
+
expect(downloadResponse.json()).toEqual({
|
|
214
|
+
success: true,
|
|
215
|
+
data: { path: "/tmp/download.txt", contentBase64: binary.toString("base64") },
|
|
216
|
+
});
|
|
217
|
+
expect(execResponse.json()).toEqual({
|
|
218
|
+
success: true,
|
|
219
|
+
data: { stdout: "ok", stderr: "", exitCode: 0 },
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
await app.close();
|
|
223
|
+
});
|
|
224
|
+
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
const buildApp = jest.fn();
|
|
2
|
+
|
|
3
|
+
import packageJson from "../../package.json";
|
|
4
|
+
|
|
5
|
+
jest.mock("../app", () => ({
|
|
6
|
+
buildApp,
|
|
7
|
+
}));
|
|
8
|
+
|
|
9
|
+
describe("package entrypoint", () => {
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
buildApp.mockReset();
|
|
12
|
+
jest.resetModules();
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("does not start the server when imported", async () => {
|
|
16
|
+
const entry = await import("../index");
|
|
17
|
+
|
|
18
|
+
expect(buildApp).not.toHaveBeenCalled();
|
|
19
|
+
expect(entry.buildApp).toBe(buildApp);
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
describe("package metadata", () => {
|
|
24
|
+
it("exposes the production CLI bin", () => {
|
|
25
|
+
expect(packageJson.bin).toEqual({
|
|
26
|
+
"lattice-microsandbox-service": "./dist/server.js",
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("uses watch-and-run dev mode", () => {
|
|
31
|
+
expect(packageJson.scripts.dev).toContain('--onSuccess "node dist/server.js"');
|
|
32
|
+
});
|
|
33
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { parseServerArgs, resolveServerConfig } from "../lib/server-cli";
|
|
2
|
+
|
|
3
|
+
describe("server CLI parsing", () => {
|
|
4
|
+
it("parses --port and --host args", () => {
|
|
5
|
+
expect(parseServerArgs(["--port", "4012", "--host", "127.0.0.1"])).toEqual({
|
|
6
|
+
port: 4012,
|
|
7
|
+
host: "127.0.0.1",
|
|
8
|
+
});
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it("prefers CLI args over env and defaults", () => {
|
|
12
|
+
expect(
|
|
13
|
+
resolveServerConfig({
|
|
14
|
+
args: { port: 4012 },
|
|
15
|
+
env: { PORT: "4002", HOST: "0.0.0.0" },
|
|
16
|
+
})
|
|
17
|
+
).toEqual({
|
|
18
|
+
port: 4012,
|
|
19
|
+
host: "0.0.0.0",
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("rejects invalid port values", () => {
|
|
24
|
+
expect(() => parseServerArgs(["--port", "abc"])).toThrow("Invalid value for --port: abc");
|
|
25
|
+
});
|
|
26
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
const listen = jest.fn();
|
|
2
|
+
const close = jest.fn();
|
|
3
|
+
|
|
4
|
+
jest.mock("../app", () => ({
|
|
5
|
+
buildApp: jest.fn(() => ({
|
|
6
|
+
listen,
|
|
7
|
+
close,
|
|
8
|
+
})),
|
|
9
|
+
}));
|
|
10
|
+
|
|
11
|
+
describe("server bootstrap", () => {
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
listen.mockReset().mockResolvedValue(undefined);
|
|
14
|
+
close.mockReset().mockResolvedValue(undefined);
|
|
15
|
+
jest.resetModules();
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("starts the app with resolved host and port", async () => {
|
|
19
|
+
const { startServer } = await import("../server");
|
|
20
|
+
|
|
21
|
+
await startServer({ argv: ["--port", "4012", "--host", "127.0.0.1"], env: {} as NodeJS.ProcessEnv });
|
|
22
|
+
|
|
23
|
+
expect(listen).toHaveBeenCalledWith({ port: 4012, host: "127.0.0.1" });
|
|
24
|
+
});
|
|
25
|
+
});
|
package/src/app.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import cors from "@fastify/cors";
|
|
2
|
+
import multipart from "@fastify/multipart";
|
|
3
|
+
import sensible from "@fastify/sensible";
|
|
4
|
+
import fastify, { type FastifyInstance } from "fastify";
|
|
5
|
+
import { toErrorResponse } from "./lib/errors";
|
|
6
|
+
import { registerHealthRoutes } from "./routes/health";
|
|
7
|
+
import { registerSandboxRoutes } from "./routes/sandbox";
|
|
8
|
+
import { MicrosandboxRuntimeService } from "./services/MicrosandboxRuntimeService";
|
|
9
|
+
import type { RuntimeService } from "./types/runtime-service";
|
|
10
|
+
|
|
11
|
+
export function buildApp({
|
|
12
|
+
runtimeService = new MicrosandboxRuntimeService(),
|
|
13
|
+
}: {
|
|
14
|
+
runtimeService?: RuntimeService;
|
|
15
|
+
} = {}): FastifyInstance {
|
|
16
|
+
const app = fastify({ logger: false });
|
|
17
|
+
|
|
18
|
+
app.register(cors, { origin: true });
|
|
19
|
+
app.register(sensible);
|
|
20
|
+
app.register(multipart);
|
|
21
|
+
|
|
22
|
+
registerHealthRoutes(app);
|
|
23
|
+
registerSandboxRoutes(app, runtimeService);
|
|
24
|
+
|
|
25
|
+
app.setErrorHandler((error, _request, reply) => {
|
|
26
|
+
const { statusCode, body } = toErrorResponse(error);
|
|
27
|
+
reply.status(statusCode).send(body);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
return app;
|
|
31
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import type { FastifyReply, FastifyRequest } from "fastify";
|
|
2
|
+
import {
|
|
3
|
+
downloadFileSchema,
|
|
4
|
+
ensureSandboxSchema,
|
|
5
|
+
findFilesSchema,
|
|
6
|
+
listPathSchema,
|
|
7
|
+
readFileSchema,
|
|
8
|
+
replaceInFileSchema,
|
|
9
|
+
searchInFileSchema,
|
|
10
|
+
shellExecSchema,
|
|
11
|
+
uploadFileSchema,
|
|
12
|
+
writeFileSchema,
|
|
13
|
+
} from "../schemas/sandbox";
|
|
14
|
+
import { ok } from "../lib/http";
|
|
15
|
+
import type { RuntimeService } from "../types/runtime-service";
|
|
16
|
+
|
|
17
|
+
export function createSandboxController(runtimeService: RuntimeService): {
|
|
18
|
+
ensureSandbox(
|
|
19
|
+
request: FastifyRequest<{ Params: { name: string }; Body: unknown }>,
|
|
20
|
+
reply: FastifyReply
|
|
21
|
+
): Promise<FastifyReply>;
|
|
22
|
+
startSandbox(
|
|
23
|
+
request: FastifyRequest<{ Params: { name: string } }>,
|
|
24
|
+
reply: FastifyReply
|
|
25
|
+
): Promise<FastifyReply>;
|
|
26
|
+
stopSandbox(
|
|
27
|
+
request: FastifyRequest<{ Params: { name: string } }>,
|
|
28
|
+
reply: FastifyReply
|
|
29
|
+
): Promise<FastifyReply>;
|
|
30
|
+
killSandbox(
|
|
31
|
+
request: FastifyRequest<{ Params: { name: string } }>,
|
|
32
|
+
reply: FastifyReply
|
|
33
|
+
): Promise<FastifyReply>;
|
|
34
|
+
deleteSandbox(
|
|
35
|
+
request: FastifyRequest<{ Params: { name: string } }>,
|
|
36
|
+
reply: FastifyReply
|
|
37
|
+
): Promise<FastifyReply>;
|
|
38
|
+
getStatus(
|
|
39
|
+
request: FastifyRequest<{ Params: { name: string } }>,
|
|
40
|
+
reply: FastifyReply
|
|
41
|
+
): Promise<FastifyReply>;
|
|
42
|
+
readFile(request: FastifyRequest<{ Body: unknown }>, reply: FastifyReply): Promise<FastifyReply>;
|
|
43
|
+
writeFile(request: FastifyRequest<{ Body: unknown }>, reply: FastifyReply): Promise<FastifyReply>;
|
|
44
|
+
listPath(request: FastifyRequest<{ Body: unknown }>, reply: FastifyReply): Promise<FastifyReply>;
|
|
45
|
+
findFiles(request: FastifyRequest<{ Body: unknown }>, reply: FastifyReply): Promise<FastifyReply>;
|
|
46
|
+
searchInFile(request: FastifyRequest<{ Body: unknown }>, reply: FastifyReply): Promise<FastifyReply>;
|
|
47
|
+
replaceInFile(request: FastifyRequest<{ Body: unknown }>, reply: FastifyReply): Promise<FastifyReply>;
|
|
48
|
+
uploadFile(request: FastifyRequest<{ Body: unknown }>, reply: FastifyReply): Promise<FastifyReply>;
|
|
49
|
+
downloadFile(
|
|
50
|
+
request: FastifyRequest<{ Querystring: unknown }>,
|
|
51
|
+
reply: FastifyReply
|
|
52
|
+
): Promise<FastifyReply>;
|
|
53
|
+
execCommand(request: FastifyRequest<{ Body: unknown }>, reply: FastifyReply): Promise<FastifyReply>;
|
|
54
|
+
} {
|
|
55
|
+
return {
|
|
56
|
+
ensureSandbox: async (
|
|
57
|
+
request: FastifyRequest<{ Params: { name: string }; Body: unknown }>,
|
|
58
|
+
reply: FastifyReply
|
|
59
|
+
) => {
|
|
60
|
+
const body = ensureSandboxSchema.parse(request.body ?? {});
|
|
61
|
+
const result = await runtimeService.ensureSandbox(request.params.name, body);
|
|
62
|
+
return reply.send(ok(result));
|
|
63
|
+
},
|
|
64
|
+
|
|
65
|
+
startSandbox: async (request, reply) => {
|
|
66
|
+
return reply.send(ok(await runtimeService.startSandbox(request.params.name)));
|
|
67
|
+
},
|
|
68
|
+
|
|
69
|
+
stopSandbox: async (request, reply) => {
|
|
70
|
+
return reply.send(ok(await runtimeService.stopSandbox(request.params.name)));
|
|
71
|
+
},
|
|
72
|
+
|
|
73
|
+
killSandbox: async (request, reply) => {
|
|
74
|
+
return reply.send(ok(await runtimeService.killSandbox(request.params.name)));
|
|
75
|
+
},
|
|
76
|
+
|
|
77
|
+
deleteSandbox: async (request, reply) => {
|
|
78
|
+
return reply.send(ok(await runtimeService.deleteSandbox(request.params.name)));
|
|
79
|
+
},
|
|
80
|
+
|
|
81
|
+
getStatus: async (request, reply) => {
|
|
82
|
+
return reply.send(ok(await runtimeService.getStatus(request.params.name)));
|
|
83
|
+
},
|
|
84
|
+
|
|
85
|
+
readFile: async (request, reply) => {
|
|
86
|
+
const body = readFileSchema.parse(request.body ?? {});
|
|
87
|
+
return reply.send(ok(await runtimeService.readFile(body.sandboxName, body.path)));
|
|
88
|
+
},
|
|
89
|
+
|
|
90
|
+
writeFile: async (request, reply) => {
|
|
91
|
+
const body = writeFileSchema.parse(request.body ?? {});
|
|
92
|
+
return reply.send(ok(await runtimeService.writeFile(body.sandboxName, body.path, body.content)));
|
|
93
|
+
},
|
|
94
|
+
|
|
95
|
+
listPath: async (request, reply) => {
|
|
96
|
+
const body = listPathSchema.parse(request.body ?? {});
|
|
97
|
+
return reply.send(ok(await runtimeService.listPath(body.sandboxName, body.path, body.recursive)));
|
|
98
|
+
},
|
|
99
|
+
|
|
100
|
+
findFiles: async (request, reply) => {
|
|
101
|
+
const body = findFilesSchema.parse(request.body ?? {});
|
|
102
|
+
return reply.send(ok(await runtimeService.findFiles(body.sandboxName, body.path, body.pattern)));
|
|
103
|
+
},
|
|
104
|
+
|
|
105
|
+
searchInFile: async (request, reply) => {
|
|
106
|
+
const body = searchInFileSchema.parse(request.body ?? {});
|
|
107
|
+
return reply.send(ok(await runtimeService.searchInFile(body.sandboxName, body.path, body.query)));
|
|
108
|
+
},
|
|
109
|
+
|
|
110
|
+
replaceInFile: async (request, reply) => {
|
|
111
|
+
const body = replaceInFileSchema.parse(request.body ?? {});
|
|
112
|
+
return reply.send(
|
|
113
|
+
ok(
|
|
114
|
+
await runtimeService.replaceInFile(body.sandboxName, {
|
|
115
|
+
path: body.path,
|
|
116
|
+
search: body.search,
|
|
117
|
+
replace: body.replace,
|
|
118
|
+
})
|
|
119
|
+
)
|
|
120
|
+
);
|
|
121
|
+
},
|
|
122
|
+
|
|
123
|
+
uploadFile: async (request, reply) => {
|
|
124
|
+
const body = uploadFileSchema.parse(request.body ?? {});
|
|
125
|
+
return reply.send(ok(await runtimeService.uploadFile(body.sandboxName, body.path, body.contentBase64)));
|
|
126
|
+
},
|
|
127
|
+
|
|
128
|
+
downloadFile: async (request, reply) => {
|
|
129
|
+
const query = downloadFileSchema.parse(request.query ?? {});
|
|
130
|
+
return reply.send(ok(await runtimeService.downloadFile(query.sandboxName, query.path)));
|
|
131
|
+
},
|
|
132
|
+
|
|
133
|
+
execCommand: async (request, reply) => {
|
|
134
|
+
const body = shellExecSchema.parse(request.body ?? {});
|
|
135
|
+
return reply.send(ok(await runtimeService.execCommand(body)));
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
export class HttpError extends Error {
|
|
2
|
+
constructor(
|
|
3
|
+
public statusCode: number,
|
|
4
|
+
public code: string,
|
|
5
|
+
message: string
|
|
6
|
+
) {
|
|
7
|
+
super(message);
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function toErrorResponse(error: unknown): {
|
|
12
|
+
statusCode: number;
|
|
13
|
+
body: {
|
|
14
|
+
success: false;
|
|
15
|
+
error: {
|
|
16
|
+
code: string;
|
|
17
|
+
message: string;
|
|
18
|
+
};
|
|
19
|
+
};
|
|
20
|
+
} {
|
|
21
|
+
if (error instanceof HttpError) {
|
|
22
|
+
return {
|
|
23
|
+
statusCode: error.statusCode,
|
|
24
|
+
body: {
|
|
25
|
+
success: false,
|
|
26
|
+
error: {
|
|
27
|
+
code: error.code,
|
|
28
|
+
message: error.message,
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return {
|
|
35
|
+
statusCode: 500,
|
|
36
|
+
body: {
|
|
37
|
+
success: false,
|
|
38
|
+
error: {
|
|
39
|
+
code: "INTERNAL_ERROR",
|
|
40
|
+
message: error instanceof Error ? error.message : String(error),
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
}
|
package/src/lib/http.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
export interface ParsedServerArgs {
|
|
2
|
+
host?: string;
|
|
3
|
+
port?: number;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export interface ResolvedServerConfig {
|
|
7
|
+
host: string;
|
|
8
|
+
port: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function parsePort(raw: string, source: string): number {
|
|
12
|
+
const value = Number(raw);
|
|
13
|
+
if (!Number.isInteger(value) || value <= 0) {
|
|
14
|
+
throw new Error(`Invalid value for ${source}: ${raw}`);
|
|
15
|
+
}
|
|
16
|
+
return value;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function parseServerArgs(argv: string[]): ParsedServerArgs {
|
|
20
|
+
const parsed: ParsedServerArgs = {};
|
|
21
|
+
|
|
22
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
23
|
+
const token = argv[index];
|
|
24
|
+
|
|
25
|
+
if (token === "--port") {
|
|
26
|
+
const raw = argv[index + 1];
|
|
27
|
+
if (!raw) {
|
|
28
|
+
throw new Error("Missing value for --port");
|
|
29
|
+
}
|
|
30
|
+
parsed.port = parsePort(raw, "--port");
|
|
31
|
+
index += 1;
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (token === "--host") {
|
|
36
|
+
const raw = argv[index + 1];
|
|
37
|
+
if (!raw) {
|
|
38
|
+
throw new Error("Missing value for --host");
|
|
39
|
+
}
|
|
40
|
+
parsed.host = raw;
|
|
41
|
+
index += 1;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return parsed;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function resolveServerConfig({
|
|
49
|
+
args,
|
|
50
|
+
env,
|
|
51
|
+
}: {
|
|
52
|
+
args: ParsedServerArgs;
|
|
53
|
+
env: NodeJS.ProcessEnv;
|
|
54
|
+
}): ResolvedServerConfig {
|
|
55
|
+
return {
|
|
56
|
+
host: args.host ?? env.HOST ?? "0.0.0.0",
|
|
57
|
+
port: args.port ?? (env.PORT ? parsePort(env.PORT, "PORT") : 4002),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { FastifyInstance } from "fastify";
|
|
2
|
+
import { createSandboxController } from "../controllers/sandbox";
|
|
3
|
+
import type { RuntimeService } from "../types/runtime-service";
|
|
4
|
+
|
|
5
|
+
export function registerSandboxRoutes(
|
|
6
|
+
app: FastifyInstance,
|
|
7
|
+
runtimeService: RuntimeService
|
|
8
|
+
): void {
|
|
9
|
+
const controller = createSandboxController(runtimeService);
|
|
10
|
+
|
|
11
|
+
app.put("/api/sandboxes/:name", controller.ensureSandbox);
|
|
12
|
+
app.post("/api/sandboxes/:name/start", controller.startSandbox);
|
|
13
|
+
app.post("/api/sandboxes/:name/stop", controller.stopSandbox);
|
|
14
|
+
app.post("/api/sandboxes/:name/kill", controller.killSandbox);
|
|
15
|
+
app.delete("/api/sandboxes/:name", controller.deleteSandbox);
|
|
16
|
+
app.get("/api/sandboxes/:name/status", controller.getStatus);
|
|
17
|
+
|
|
18
|
+
app.post("/api/files/read", controller.readFile);
|
|
19
|
+
app.post("/api/files/write", controller.writeFile);
|
|
20
|
+
app.post("/api/files/list", controller.listPath);
|
|
21
|
+
app.post("/api/files/find", controller.findFiles);
|
|
22
|
+
app.post("/api/files/search", controller.searchInFile);
|
|
23
|
+
app.post("/api/files/replace", controller.replaceInFile);
|
|
24
|
+
app.post("/api/files/upload", controller.uploadFile);
|
|
25
|
+
app.get("/api/files/download", controller.downloadFile);
|
|
26
|
+
|
|
27
|
+
app.post("/api/shell/exec", controller.execCommand);
|
|
28
|
+
}
|