@cjhyy/code-shell-server 0.9.7 → 0.9.9
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/README.md +85 -23
- package/dist/bin/code-shell-project-runtime.d.ts +2 -0
- package/dist/bin/code-shell-project-runtime.js +8 -0
- package/dist/desktop-web/http-api.d.ts +43 -0
- package/dist/desktop-web/http-api.js +376 -0
- package/dist/hub/approval-lease.d.ts +30 -0
- package/dist/hub/approval-lease.js +89 -0
- package/dist/hub/auth-http.d.ts +23 -0
- package/dist/hub/auth-http.js +259 -0
- package/dist/hub/auth-store.d.ts +52 -0
- package/dist/hub/auth-store.js +316 -0
- package/dist/hub/configuration.d.ts +64 -0
- package/dist/hub/configuration.js +555 -0
- package/dist/hub/files.d.ts +9 -0
- package/dist/hub/files.js +278 -0
- package/dist/hub/mcp-configuration.d.ts +51 -0
- package/dist/hub/mcp-configuration.js +558 -0
- package/dist/hub/mcp-probe.d.ts +24 -0
- package/dist/hub/mcp-probe.js +392 -0
- package/dist/hub/model-probe.d.ts +20 -0
- package/dist/hub/model-probe.js +193 -0
- package/dist/hub/outbound-transport.d.ts +36 -0
- package/dist/hub/outbound-transport.js +122 -0
- package/dist/hub/run-replay.d.ts +42 -0
- package/dist/hub/run-replay.js +89 -0
- package/dist/hub/session-management.d.ts +36 -0
- package/dist/hub/session-management.js +405 -0
- package/dist/hub/skills-management.d.ts +43 -0
- package/dist/hub/skills-management.js +487 -0
- package/dist/hub/uploads.d.ts +22 -0
- package/dist/hub/uploads.js +241 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.desktop-web.d.ts +2 -0
- package/dist/index.desktop-web.js +2 -0
- package/dist/index.js +3 -0
- package/dist/index.links.d.ts +6 -0
- package/dist/index.links.js +6 -0
- package/dist/index.panels.d.ts +9 -0
- package/dist/index.panels.js +8 -0
- package/dist/index.serve.d.ts +1 -0
- package/dist/index.serve.js +1 -0
- package/dist/index.storage.d.ts +2 -0
- package/dist/index.storage.js +2 -0
- package/dist/links/catalog.d.ts +10 -0
- package/dist/links/catalog.js +41 -0
- package/dist/links/device-oauth.d.ts +52 -0
- package/dist/links/device-oauth.js +255 -0
- package/dist/links/http.d.ts +34 -0
- package/dist/links/http.js +144 -0
- package/dist/links/service.d.ts +54 -0
- package/dist/links/service.js +578 -0
- package/dist/links/types.d.ts +6 -0
- package/dist/links/types.js +1 -0
- package/dist/mobile-remote/mobile-client-event-validator.js +5 -2
- package/dist/mobile-remote/pending-approvals.js +2 -0
- package/dist/mobile-remote/remote-host-manager.d.ts +8 -0
- package/dist/mobile-remote/remote-host-manager.js +90 -15
- package/dist/mobile-remote/room-manager.js +17 -5
- package/dist/panels/agent-task-host.d.ts +32 -0
- package/dist/panels/agent-task-host.js +457 -0
- package/dist/panels/agent-task-models.d.ts +27 -0
- package/dist/panels/agent-task-models.js +51 -0
- package/dist/panels/agent-task-service.d.ts +105 -0
- package/dist/panels/agent-task-service.js +283 -0
- package/dist/panels/http.d.ts +34 -0
- package/dist/panels/http.js +37 -0
- package/dist/panels/hub-binding.d.ts +12 -0
- package/dist/panels/hub-binding.js +174 -0
- package/dist/panels/management-http.d.ts +29 -0
- package/dist/panels/management-http.js +119 -0
- package/dist/panels/management.d.ts +39 -0
- package/dist/panels/management.js +494 -0
- package/dist/panels/process-files.d.ts +14 -0
- package/dist/panels/process-files.js +299 -0
- package/dist/panels/process-service.d.ts +96 -0
- package/dist/panels/process-service.js +501 -0
- package/dist/panels/runtime-services.d.ts +24 -0
- package/dist/panels/runtime-services.js +462 -0
- package/dist/panels/runtime.d.ts +51 -0
- package/dist/panels/runtime.js +1063 -0
- package/dist/panels/storage-store.d.ts +9 -0
- package/dist/panels/storage-store.js +154 -0
- package/dist/panels/types.d.ts +52 -0
- package/dist/panels/types.js +1 -0
- package/dist/project-runtime/control-server.d.ts +19 -0
- package/dist/project-runtime/control-server.js +300 -0
- package/dist/project-runtime/docker-provider.d.ts +33 -0
- package/dist/project-runtime/docker-provider.js +487 -0
- package/dist/project-runtime/lock.d.ts +6 -0
- package/dist/project-runtime/lock.js +43 -0
- package/dist/project-runtime/managed-entry.d.ts +10 -0
- package/dist/project-runtime/managed-entry.js +113 -0
- package/dist/project-runtime/manager.d.ts +40 -0
- package/dist/project-runtime/manager.js +249 -0
- package/dist/project-runtime/proxy.d.ts +22 -0
- package/dist/project-runtime/proxy.js +706 -0
- package/dist/project-runtime/registry.d.ts +54 -0
- package/dist/project-runtime/registry.js +286 -0
- package/dist/project-runtime/types.d.ts +43 -0
- package/dist/project-runtime/types.js +1 -0
- package/dist/serve/cli.d.ts +5 -0
- package/dist/serve/cli.js +82 -23
- package/dist/serve/headless-server.d.ts +11 -0
- package/dist/serve/headless-server.js +737 -83
- package/dist/session-titles-store.d.ts +17 -0
- package/dist/session-titles-store.js +174 -0
- package/dist/static-files.js +1 -0
- package/dist/worker-bridge-core.js +17 -6
- package/package.json +19 -5
|
@@ -0,0 +1,487 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
3
|
+
import { chmod, lstat, mkdir, open, rename, rm } from "node:fs/promises";
|
|
4
|
+
import { join, resolve } from "node:path";
|
|
5
|
+
export const DEFAULT_PROJECT_RUNTIME_IMAGE = "codeshell-project-runtime:local";
|
|
6
|
+
export const PROJECT_RUNTIME_SECRET_PATH = "/run/codeshell-project.json";
|
|
7
|
+
export const PROJECT_RUNTIME_LABEL = "io.codeshell.project-runtime";
|
|
8
|
+
const UUID = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/;
|
|
9
|
+
export class ProjectRuntimeError extends Error {
|
|
10
|
+
constructor(message) {
|
|
11
|
+
super(message);
|
|
12
|
+
this.name = "ProjectRuntimeError";
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
function dockerCommand(args, { signal }) {
|
|
16
|
+
return new Promise((resolve, reject) => {
|
|
17
|
+
execFile("docker", args, { signal, encoding: "utf8", maxBuffer: 1024 * 1024, windowsHide: true }, (error, stdout, stderr) => {
|
|
18
|
+
if (error && typeof error.code !== "number") {
|
|
19
|
+
reject(new ProjectRuntimeError("Docker command is unavailable or was interrupted."));
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
resolve({
|
|
23
|
+
stdout,
|
|
24
|
+
stderr,
|
|
25
|
+
exitCode: error && typeof error.code === "number" ? error.code : 0,
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
function bounded(value, min, max, label) {
|
|
31
|
+
if (!Number.isFinite(value) || value < min || value > max)
|
|
32
|
+
throw new ProjectRuntimeError(`Invalid project ${label} limit.`);
|
|
33
|
+
return value;
|
|
34
|
+
}
|
|
35
|
+
export function validateProjectRuntimeRecord(project) {
|
|
36
|
+
if (!UUID.test(project.id) ||
|
|
37
|
+
typeof project.ownerId !== "string" ||
|
|
38
|
+
!/^[A-Za-z0-9._-]{1,128}$/.test(project.ownerId) ||
|
|
39
|
+
!Number.isSafeInteger(project.generation) ||
|
|
40
|
+
project.generation < 0 ||
|
|
41
|
+
!/^[A-Za-z0-9._-]{1,64}$/.test(project.runtimeUsername) ||
|
|
42
|
+
typeof project.runtimePassword !== "string" ||
|
|
43
|
+
project.runtimePassword.length < 32 ||
|
|
44
|
+
project.runtimePassword.length > 256 ||
|
|
45
|
+
/[\0\r\n]/.test(project.runtimePassword))
|
|
46
|
+
throw new ProjectRuntimeError("Invalid managed project identity or credentials.");
|
|
47
|
+
}
|
|
48
|
+
export function validateProjectPublicOrigin(value) {
|
|
49
|
+
const url = new URL(value);
|
|
50
|
+
if (!["http:", "https:"].includes(url.protocol) ||
|
|
51
|
+
url.username ||
|
|
52
|
+
url.password ||
|
|
53
|
+
url.pathname !== "/" ||
|
|
54
|
+
url.search ||
|
|
55
|
+
url.hash ||
|
|
56
|
+
value !== url.origin)
|
|
57
|
+
throw new ProjectRuntimeError("Project publicOrigin must be an exact HTTP(S) origin.");
|
|
58
|
+
return url.origin;
|
|
59
|
+
}
|
|
60
|
+
function labels(project, installation) {
|
|
61
|
+
return {
|
|
62
|
+
[`${PROJECT_RUNTIME_LABEL}.installation`]: installation,
|
|
63
|
+
[`${PROJECT_RUNTIME_LABEL}.project`]: project.id,
|
|
64
|
+
[`${PROJECT_RUNTIME_LABEL}.owner`]: project.ownerId,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
function mustOwn(resource, expected) {
|
|
68
|
+
const actual = resource?.Config?.Labels ?? resource?.Labels;
|
|
69
|
+
if (!actual || Object.entries(expected).some(([key, value]) => actual[key] !== value))
|
|
70
|
+
throw new ProjectRuntimeError("Docker resource identity does not match this project.");
|
|
71
|
+
}
|
|
72
|
+
function connectionUrl(resource) {
|
|
73
|
+
const bindings = resource?.NetworkSettings?.Ports?.["8790/tcp"];
|
|
74
|
+
if (!Array.isArray(bindings) ||
|
|
75
|
+
bindings.length !== 1 ||
|
|
76
|
+
bindings[0]?.HostIp !== "127.0.0.1" ||
|
|
77
|
+
!/^\d{1,5}$/.test(bindings[0]?.HostPort ?? "") ||
|
|
78
|
+
Number(bindings[0].HostPort) < 1 ||
|
|
79
|
+
Number(bindings[0].HostPort) > 65535)
|
|
80
|
+
throw new ProjectRuntimeError("Project container must publish only a loopback runtime port.");
|
|
81
|
+
return `http://127.0.0.1:${Number(bindings[0].HostPort)}`;
|
|
82
|
+
}
|
|
83
|
+
function interrupted(signal) {
|
|
84
|
+
if (signal.aborted)
|
|
85
|
+
throw new ProjectRuntimeError("Project operation was cancelled or timed out.");
|
|
86
|
+
}
|
|
87
|
+
async function within(promise, signal) {
|
|
88
|
+
interrupted(signal);
|
|
89
|
+
let onAbort;
|
|
90
|
+
try {
|
|
91
|
+
return await Promise.race([
|
|
92
|
+
promise,
|
|
93
|
+
new Promise((_, reject) => {
|
|
94
|
+
onAbort = () => reject(new ProjectRuntimeError("Project operation was cancelled or timed out."));
|
|
95
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
96
|
+
}),
|
|
97
|
+
]);
|
|
98
|
+
}
|
|
99
|
+
finally {
|
|
100
|
+
if (onAbort)
|
|
101
|
+
signal.removeEventListener("abort", onAbort);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
/** An administrator-owned Docker control client. Project tasks never receive this client. */
|
|
105
|
+
export function createDockerProjectProvider(options) {
|
|
106
|
+
if (!UUID.test(options.installationId))
|
|
107
|
+
throw new ProjectRuntimeError("Invalid installation id.");
|
|
108
|
+
const image = options.image ?? DEFAULT_PROJECT_RUNTIME_IMAGE;
|
|
109
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._/:@-]{0,255}$/.test(image))
|
|
110
|
+
throw new ProjectRuntimeError("Invalid project runtime image.");
|
|
111
|
+
const dataDir = resolve(options.dataDir);
|
|
112
|
+
if (/[\0,\r\n]/.test(dataDir))
|
|
113
|
+
throw new ProjectRuntimeError("Unsupported project metadata path.");
|
|
114
|
+
const memory = bounded(options.limits?.memoryMb ?? 2048, 128, 262144, "memory");
|
|
115
|
+
const cpus = bounded(options.limits?.cpus ?? 2, 0.1, 128, "CPU");
|
|
116
|
+
const pids = bounded(options.limits?.pids ?? 256, 16, 65536, "process");
|
|
117
|
+
if (!Number.isInteger(memory) || !Number.isInteger(pids))
|
|
118
|
+
throw new ProjectRuntimeError("Invalid resource limits.");
|
|
119
|
+
const commandTimeout = bounded(options.commandTimeoutMs ?? 15000, 10, 120000, "command timeout");
|
|
120
|
+
const startupTimeout = bounded(options.startupTimeoutMs ?? 90000, 10, 300000, "startup timeout");
|
|
121
|
+
const command = options.command ?? dockerCommand;
|
|
122
|
+
const healthCheck = options.healthCheck ??
|
|
123
|
+
(async (url, signal) => {
|
|
124
|
+
const response = await fetch(`${url}/health`, {
|
|
125
|
+
signal: AbortSignal.any([signal, AbortSignal.timeout(2000)]),
|
|
126
|
+
redirect: "error",
|
|
127
|
+
});
|
|
128
|
+
await response.body?.cancel();
|
|
129
|
+
return response.ok;
|
|
130
|
+
});
|
|
131
|
+
const lifetime = new AbortController();
|
|
132
|
+
const pending = new Map();
|
|
133
|
+
const stops = new Map();
|
|
134
|
+
const operations = new Set();
|
|
135
|
+
function names(project) {
|
|
136
|
+
const base = `codeshell-${options.installationId}-${project.id}`;
|
|
137
|
+
return {
|
|
138
|
+
container: base,
|
|
139
|
+
data: `${base}-data`,
|
|
140
|
+
workspace: `${base}-workspace`,
|
|
141
|
+
network: `${base}-network`,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
async function invoke(args, signal = lifetime.signal) {
|
|
145
|
+
const boundedSignal = AbortSignal.any([
|
|
146
|
+
signal,
|
|
147
|
+
lifetime.signal,
|
|
148
|
+
AbortSignal.timeout(commandTimeout),
|
|
149
|
+
]);
|
|
150
|
+
interrupted(boundedSignal);
|
|
151
|
+
return within(command(args, { signal: boundedSignal }), boundedSignal);
|
|
152
|
+
}
|
|
153
|
+
async function successful(args, signal) {
|
|
154
|
+
const result = await invoke(args, signal);
|
|
155
|
+
if (result.exitCode !== 0)
|
|
156
|
+
throw new ProjectRuntimeError(`Docker ${args[0]} operation failed.`);
|
|
157
|
+
return result.stdout;
|
|
158
|
+
}
|
|
159
|
+
async function inspect(kind, name, signal) {
|
|
160
|
+
const result = await invoke([kind, "inspect", name], signal);
|
|
161
|
+
if (result.exitCode !== 0) {
|
|
162
|
+
if (/no such (?:container|network|volume|object)|not found/i.test(result.stderr))
|
|
163
|
+
return null;
|
|
164
|
+
throw new ProjectRuntimeError(`Cannot inspect project Docker ${kind}.`);
|
|
165
|
+
}
|
|
166
|
+
try {
|
|
167
|
+
const parsed = JSON.parse(result.stdout);
|
|
168
|
+
if (!Array.isArray(parsed) || parsed.length !== 1 || !parsed[0])
|
|
169
|
+
throw new Error();
|
|
170
|
+
return parsed[0];
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
throw new ProjectRuntimeError(`Invalid Docker ${kind} inspection response.`);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
async function resource(kind, name, expected, signal) {
|
|
177
|
+
const existing = await inspect(kind, name, signal);
|
|
178
|
+
if (existing) {
|
|
179
|
+
mustOwn(existing, expected);
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
const args = [kind, "create"];
|
|
183
|
+
for (const [key, value] of Object.entries(expected))
|
|
184
|
+
args.push("--label", `${key}=${value}`);
|
|
185
|
+
if (kind === "network")
|
|
186
|
+
args.push("--driver", "bridge", "--opt", "com.docker.network.bridge.enable_icc=false");
|
|
187
|
+
args.push(name);
|
|
188
|
+
await successful(args, signal);
|
|
189
|
+
interrupted(signal);
|
|
190
|
+
mustOwn(await inspect(kind, name, signal), expected);
|
|
191
|
+
}
|
|
192
|
+
async function secretFile(project, secret, signal) {
|
|
193
|
+
const directory = join(dataDir, "project-runtime-secrets", project.id);
|
|
194
|
+
for (const dir of [join(dataDir, "project-runtime-secrets"), directory]) {
|
|
195
|
+
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
196
|
+
interrupted(signal);
|
|
197
|
+
const info = await lstat(dir);
|
|
198
|
+
if (!info.isDirectory() || info.isSymbolicLink())
|
|
199
|
+
throw new ProjectRuntimeError("Unsafe project secret directory.");
|
|
200
|
+
await chmod(dir, 0o700);
|
|
201
|
+
interrupted(signal);
|
|
202
|
+
}
|
|
203
|
+
const destination = join(directory, "runtime.json");
|
|
204
|
+
const temporary = join(directory, `${randomUUID()}.tmp`);
|
|
205
|
+
try {
|
|
206
|
+
const handle = await open(temporary, "wx", 0o600);
|
|
207
|
+
try {
|
|
208
|
+
interrupted(signal);
|
|
209
|
+
await handle.writeFile(`${JSON.stringify(secret)}\n`, "utf8");
|
|
210
|
+
// The parent is private (0700); only this file is mounted, read-only, into
|
|
211
|
+
// the container. World-readable file mode supports its non-root UID.
|
|
212
|
+
await handle.chmod(0o444);
|
|
213
|
+
}
|
|
214
|
+
finally {
|
|
215
|
+
await handle.close();
|
|
216
|
+
}
|
|
217
|
+
interrupted(signal);
|
|
218
|
+
await rename(temporary, destination);
|
|
219
|
+
interrupted(signal);
|
|
220
|
+
return destination;
|
|
221
|
+
}
|
|
222
|
+
finally {
|
|
223
|
+
await rm(temporary, { force: true });
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
function track(promise) {
|
|
227
|
+
operations.add(promise);
|
|
228
|
+
void promise.finally(() => operations.delete(promise)).catch(() => { });
|
|
229
|
+
return promise;
|
|
230
|
+
}
|
|
231
|
+
async function stopOwned(project) {
|
|
232
|
+
const name = names(project).container;
|
|
233
|
+
const existing = await inspect("container", name);
|
|
234
|
+
if (!existing)
|
|
235
|
+
return;
|
|
236
|
+
mustOwn(existing, labels(project, options.installationId));
|
|
237
|
+
if (existing.State?.Running)
|
|
238
|
+
await successful(["container", "stop", "--time", "5", name]);
|
|
239
|
+
}
|
|
240
|
+
async function start(project, publicOrigin, signal) {
|
|
241
|
+
const expected = labels(project, options.installationId);
|
|
242
|
+
const named = names(project);
|
|
243
|
+
const secret = {
|
|
244
|
+
version: 1,
|
|
245
|
+
projectId: project.id,
|
|
246
|
+
ownerId: project.ownerId,
|
|
247
|
+
generation: project.generation,
|
|
248
|
+
username: project.runtimeUsername,
|
|
249
|
+
password: project.runtimePassword,
|
|
250
|
+
publicOrigin,
|
|
251
|
+
publicPathPrefix: `/p/${project.id}`,
|
|
252
|
+
};
|
|
253
|
+
const configuration = createHash("sha256").update(JSON.stringify(secret)).digest("hex");
|
|
254
|
+
const configLabel = `${PROJECT_RUNTIME_LABEL}.configuration`;
|
|
255
|
+
const generationLabel = `${PROJECT_RUNTIME_LABEL}.generation`;
|
|
256
|
+
const imageResult = await invoke(["image", "inspect", image], signal);
|
|
257
|
+
if (imageResult.exitCode !== 0)
|
|
258
|
+
throw new ProjectRuntimeError(`Project runtime image ${image} is unavailable; build it before starting projects.`);
|
|
259
|
+
let existing = await inspect("container", named.container, signal);
|
|
260
|
+
if (existing) {
|
|
261
|
+
mustOwn(existing, expected);
|
|
262
|
+
const same = existing.Config?.Labels?.[generationLabel] === String(project.generation) &&
|
|
263
|
+
existing.Config?.Labels?.[configLabel] === configuration;
|
|
264
|
+
if (!same) {
|
|
265
|
+
if (existing.State?.Running)
|
|
266
|
+
throw new ProjectRuntimeError("A different project generation is still running; stop it before starting a new generation.");
|
|
267
|
+
await successful(["container", "rm", named.container], signal);
|
|
268
|
+
existing = null;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
if (!existing) {
|
|
272
|
+
await resource("network", named.network, expected, signal);
|
|
273
|
+
await resource("volume", named.data, expected, signal);
|
|
274
|
+
await resource("volume", named.workspace, expected, signal);
|
|
275
|
+
const file = await secretFile(project, secret, signal);
|
|
276
|
+
const args = [
|
|
277
|
+
"container",
|
|
278
|
+
"create",
|
|
279
|
+
"--name",
|
|
280
|
+
named.container,
|
|
281
|
+
"--init",
|
|
282
|
+
"--user",
|
|
283
|
+
"1000:1000",
|
|
284
|
+
"--read-only",
|
|
285
|
+
"--cap-drop",
|
|
286
|
+
"ALL",
|
|
287
|
+
"--security-opt",
|
|
288
|
+
"no-new-privileges:true",
|
|
289
|
+
"--memory",
|
|
290
|
+
`${memory}m`,
|
|
291
|
+
"--memory-swap",
|
|
292
|
+
`${memory}m`,
|
|
293
|
+
"--cpus",
|
|
294
|
+
String(cpus),
|
|
295
|
+
"--pids-limit",
|
|
296
|
+
String(pids),
|
|
297
|
+
"--ulimit",
|
|
298
|
+
"nofile=4096:4096",
|
|
299
|
+
"--shm-size",
|
|
300
|
+
"128m",
|
|
301
|
+
"--tmpfs",
|
|
302
|
+
"/tmp:rw,nosuid,nodev,size=268435456,mode=1777",
|
|
303
|
+
"--network",
|
|
304
|
+
named.network,
|
|
305
|
+
"--publish",
|
|
306
|
+
"127.0.0.1::8790",
|
|
307
|
+
"--workdir",
|
|
308
|
+
"/workspace",
|
|
309
|
+
"--mount",
|
|
310
|
+
`type=volume,source=${named.data},target=/data`,
|
|
311
|
+
"--mount",
|
|
312
|
+
`type=volume,source=${named.workspace},target=/workspace`,
|
|
313
|
+
"--mount",
|
|
314
|
+
`type=bind,source=${file},target=${PROJECT_RUNTIME_SECRET_PATH},readonly`,
|
|
315
|
+
"--env",
|
|
316
|
+
"HOME=/data/home",
|
|
317
|
+
"--env",
|
|
318
|
+
"CODE_SHELL_HOME=/data/home/.code-shell",
|
|
319
|
+
"--env",
|
|
320
|
+
"NPM_CONFIG_CACHE=/data/home/.npm",
|
|
321
|
+
"--env",
|
|
322
|
+
"NPM_CONFIG_PREFIX=/data/home/.local",
|
|
323
|
+
"--env",
|
|
324
|
+
"PATH=/data/panel-bin:/data/home/.local/bin:/data/home/.bun/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
|
|
325
|
+
"--env",
|
|
326
|
+
"PYTHONUSERBASE=/data/home/.local",
|
|
327
|
+
"--env",
|
|
328
|
+
"PIP_CACHE_DIR=/data/home/.cache/pip",
|
|
329
|
+
"--entrypoint",
|
|
330
|
+
"node",
|
|
331
|
+
];
|
|
332
|
+
for (const [key, value] of Object.entries({
|
|
333
|
+
...expected,
|
|
334
|
+
[generationLabel]: String(project.generation),
|
|
335
|
+
[configLabel]: configuration,
|
|
336
|
+
}))
|
|
337
|
+
args.push("--label", `${key}=${value}`);
|
|
338
|
+
args.push(image, "/opt/codeshell/packages/server/dist/bin/code-shell-project-runtime.js", "--secret", PROJECT_RUNTIME_SECRET_PATH);
|
|
339
|
+
await successful(args, signal);
|
|
340
|
+
interrupted(signal);
|
|
341
|
+
existing = await inspect("container", named.container, signal);
|
|
342
|
+
mustOwn(existing, expected);
|
|
343
|
+
}
|
|
344
|
+
const startedHere = !existing.State?.Running;
|
|
345
|
+
try {
|
|
346
|
+
if (startedHere)
|
|
347
|
+
await successful(["container", "start", named.container], signal);
|
|
348
|
+
interrupted(signal);
|
|
349
|
+
existing = await inspect("container", named.container, signal);
|
|
350
|
+
mustOwn(existing, {
|
|
351
|
+
...expected,
|
|
352
|
+
[generationLabel]: String(project.generation),
|
|
353
|
+
[configLabel]: configuration,
|
|
354
|
+
});
|
|
355
|
+
const url = connectionUrl(existing);
|
|
356
|
+
while (true) {
|
|
357
|
+
interrupted(signal);
|
|
358
|
+
try {
|
|
359
|
+
if (await within(healthCheck(url, signal), signal))
|
|
360
|
+
break;
|
|
361
|
+
}
|
|
362
|
+
catch {
|
|
363
|
+
interrupted(signal);
|
|
364
|
+
}
|
|
365
|
+
const state = await inspect("container", named.container, signal);
|
|
366
|
+
mustOwn(state, expected);
|
|
367
|
+
if (!state.State?.Running)
|
|
368
|
+
throw new ProjectRuntimeError("Project runtime exited before becoming ready.");
|
|
369
|
+
await within(new Promise((resolve) => setTimeout(resolve, 200)), signal);
|
|
370
|
+
}
|
|
371
|
+
interrupted(signal);
|
|
372
|
+
return {
|
|
373
|
+
url,
|
|
374
|
+
username: project.runtimeUsername,
|
|
375
|
+
password: project.runtimePassword,
|
|
376
|
+
generation: project.generation,
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
catch (error) {
|
|
380
|
+
// Readiness failures must not leave a task process running behind a failed
|
|
381
|
+
// project state. Only stop this exact generation, never delete its volumes.
|
|
382
|
+
if (startedHere && !lifetime.signal.aborted) {
|
|
383
|
+
try {
|
|
384
|
+
const current = await inspect("container", named.container);
|
|
385
|
+
mustOwn(current, {
|
|
386
|
+
...expected,
|
|
387
|
+
[generationLabel]: String(project.generation),
|
|
388
|
+
[configLabel]: configuration,
|
|
389
|
+
});
|
|
390
|
+
if (current.State?.Running)
|
|
391
|
+
await successful(["container", "stop", "--time", "5", named.container]);
|
|
392
|
+
}
|
|
393
|
+
catch {
|
|
394
|
+
// Preserve the original startup error; explicit stop can retry cleanup.
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
throw error;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
return {
|
|
401
|
+
availability() {
|
|
402
|
+
return track((async () => {
|
|
403
|
+
try {
|
|
404
|
+
if ((await invoke(["info", "--format", "{{json .ServerVersion}}"])).exitCode !== 0)
|
|
405
|
+
return { available: false, error: "Docker is not running or cannot be reached." };
|
|
406
|
+
if ((await invoke(["image", "inspect", image])).exitCode !== 0)
|
|
407
|
+
return {
|
|
408
|
+
available: false,
|
|
409
|
+
error: `Build the project runtime image ${image} before starting projects.`,
|
|
410
|
+
};
|
|
411
|
+
return { available: true };
|
|
412
|
+
}
|
|
413
|
+
catch {
|
|
414
|
+
return {
|
|
415
|
+
available: false,
|
|
416
|
+
error: "Docker is not installed, is stopped, or did not respond in time.",
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
})());
|
|
420
|
+
},
|
|
421
|
+
ensure(project, { publicOrigin }) {
|
|
422
|
+
validateProjectRuntimeRecord(project);
|
|
423
|
+
if (project.generation < 1)
|
|
424
|
+
throw new ProjectRuntimeError("A project generation must be allocated before starting.");
|
|
425
|
+
validateProjectPublicOrigin(publicOrigin);
|
|
426
|
+
interrupted(lifetime.signal);
|
|
427
|
+
if (stops.has(project.id))
|
|
428
|
+
return Promise.reject(new ProjectRuntimeError("Project is stopping."));
|
|
429
|
+
const snapshot = { ...project };
|
|
430
|
+
const key = JSON.stringify({ ...snapshot, publicOrigin });
|
|
431
|
+
const current = pending.get(project.id);
|
|
432
|
+
if (current) {
|
|
433
|
+
if (current.key !== key)
|
|
434
|
+
return Promise.reject(new ProjectRuntimeError("Another project generation is starting."));
|
|
435
|
+
return current.promise;
|
|
436
|
+
}
|
|
437
|
+
const controller = new AbortController();
|
|
438
|
+
const signal = AbortSignal.any([
|
|
439
|
+
controller.signal,
|
|
440
|
+
lifetime.signal,
|
|
441
|
+
AbortSignal.timeout(startupTimeout),
|
|
442
|
+
]);
|
|
443
|
+
const promise = start(snapshot, publicOrigin, signal).finally(() => {
|
|
444
|
+
if (pending.get(project.id)?.controller === controller)
|
|
445
|
+
pending.delete(project.id);
|
|
446
|
+
});
|
|
447
|
+
pending.set(project.id, { key, controller, promise });
|
|
448
|
+
return track(promise);
|
|
449
|
+
},
|
|
450
|
+
stop(project) {
|
|
451
|
+
validateProjectRuntimeRecord(project);
|
|
452
|
+
if (project.generation === 0)
|
|
453
|
+
return Promise.resolve();
|
|
454
|
+
interrupted(lifetime.signal);
|
|
455
|
+
const current = stops.get(project.id);
|
|
456
|
+
if (current)
|
|
457
|
+
return current;
|
|
458
|
+
const snapshot = { ...project };
|
|
459
|
+
const starting = pending.get(project.id);
|
|
460
|
+
starting?.controller.abort();
|
|
461
|
+
const operation = (async () => {
|
|
462
|
+
await starting?.promise.catch(() => { });
|
|
463
|
+
await stopOwned(snapshot);
|
|
464
|
+
})().finally(() => stops.delete(project.id));
|
|
465
|
+
stops.set(project.id, operation);
|
|
466
|
+
return track(operation);
|
|
467
|
+
},
|
|
468
|
+
status(project) {
|
|
469
|
+
validateProjectRuntimeRecord(project);
|
|
470
|
+
if (project.generation === 0)
|
|
471
|
+
return Promise.resolve({ state: "missing" });
|
|
472
|
+
return track((async () => {
|
|
473
|
+
const current = await inspect("container", names(project).container);
|
|
474
|
+
if (!current)
|
|
475
|
+
return { state: "missing" };
|
|
476
|
+
mustOwn(current, labels(project, options.installationId));
|
|
477
|
+
return current.State?.Running
|
|
478
|
+
? { state: "running", url: connectionUrl(current) }
|
|
479
|
+
: { state: "stopped" };
|
|
480
|
+
})());
|
|
481
|
+
},
|
|
482
|
+
async close() {
|
|
483
|
+
lifetime.abort();
|
|
484
|
+
await Promise.allSettled([...operations]);
|
|
485
|
+
},
|
|
486
|
+
};
|
|
487
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { lstatSync } from "node:fs";
|
|
2
|
+
import { lockSync } from "@cjhyy/code-shell-core/internal";
|
|
3
|
+
/** One control process per directory; heartbeat leases recover after a crash. */
|
|
4
|
+
export function acquireProjectControllerLock(directory) {
|
|
5
|
+
const original = lstatSync(directory);
|
|
6
|
+
if (!original.isDirectory() || original.isSymbolicLink())
|
|
7
|
+
throw new Error("Unsafe project control directory.");
|
|
8
|
+
let compromised = false;
|
|
9
|
+
let released = false;
|
|
10
|
+
let unlock;
|
|
11
|
+
try {
|
|
12
|
+
unlock = lockSync(directory, {
|
|
13
|
+
stale: 30000,
|
|
14
|
+
update: 10000,
|
|
15
|
+
retries: 0,
|
|
16
|
+
onCompromised: () => {
|
|
17
|
+
compromised = true;
|
|
18
|
+
},
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
throw new Error("Another project controller owns this data directory; stop it before starting another.");
|
|
23
|
+
}
|
|
24
|
+
return {
|
|
25
|
+
assertHeld() {
|
|
26
|
+
if (released || compromised)
|
|
27
|
+
throw new Error("Project controller lock is no longer held.");
|
|
28
|
+
const current = lstatSync(directory);
|
|
29
|
+
if (!current.isDirectory() ||
|
|
30
|
+
current.isSymbolicLink() ||
|
|
31
|
+
current.dev !== original.dev ||
|
|
32
|
+
current.ino !== original.ino)
|
|
33
|
+
throw new Error("Project control directory changed while the controller was running.");
|
|
34
|
+
},
|
|
35
|
+
release() {
|
|
36
|
+
if (released)
|
|
37
|
+
return;
|
|
38
|
+
released = true;
|
|
39
|
+
if (!compromised)
|
|
40
|
+
unlock();
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { type HeadlessServeOptions } from "../serve/headless-server.js";
|
|
2
|
+
import type { ManagedProjectSecret } from "./types.js";
|
|
3
|
+
/** Read one provider-created secret, never a control-plane account or environment dump. */
|
|
4
|
+
export declare function readManagedProjectSecret(file: string): Promise<ManagedProjectSecret>;
|
|
5
|
+
/** Initialize this project's account and invalidate every grant from a previous process. */
|
|
6
|
+
export declare function initializeManagedProjectAuth(secret: ManagedProjectSecret, dataDir: string): Promise<void>;
|
|
7
|
+
export declare function managedProjectServerOptions(secret: ManagedProjectSecret): HeadlessServeOptions & {
|
|
8
|
+
publicPathPrefix: string;
|
|
9
|
+
};
|
|
10
|
+
export declare function runManagedProjectEntry(argv?: string[]): Promise<void>;
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { constants } from "node:fs";
|
|
2
|
+
import { lstat, open } from "node:fs/promises";
|
|
3
|
+
import { HubAuthStore } from "../hub/auth-store.js";
|
|
4
|
+
import { startHeadlessServer } from "../serve/headless-server.js";
|
|
5
|
+
import { resolveWebAppRoot, resolveWorkerCapabilityModules, resolveWorkerEntry, } from "../serve/cli.js";
|
|
6
|
+
import { validateProjectPublicOrigin, validateProjectRuntimeRecord } from "./docker-provider.js";
|
|
7
|
+
const MAX_SECRET_BYTES = 16 * 1024;
|
|
8
|
+
/** Read one provider-created secret, never a control-plane account or environment dump. */
|
|
9
|
+
export async function readManagedProjectSecret(file) {
|
|
10
|
+
const entry = await lstat(file);
|
|
11
|
+
if (!entry.isFile() ||
|
|
12
|
+
entry.isSymbolicLink() ||
|
|
13
|
+
entry.size > MAX_SECRET_BYTES ||
|
|
14
|
+
entry.mode & 0o222)
|
|
15
|
+
throw new Error("Managed project secret must be a small read-only regular file.");
|
|
16
|
+
const handle = await open(file, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
|
|
17
|
+
let value;
|
|
18
|
+
try {
|
|
19
|
+
const opened = await handle.stat();
|
|
20
|
+
if (!opened.isFile() ||
|
|
21
|
+
opened.ino !== entry.ino ||
|
|
22
|
+
opened.dev !== entry.dev ||
|
|
23
|
+
opened.size > MAX_SECRET_BYTES)
|
|
24
|
+
throw new Error("Managed project secret changed while opening it.");
|
|
25
|
+
const bytes = Buffer.alloc(MAX_SECRET_BYTES + 1);
|
|
26
|
+
const { bytesRead } = await handle.read(bytes, 0, bytes.length, 0);
|
|
27
|
+
if (bytesRead > MAX_SECRET_BYTES)
|
|
28
|
+
throw new Error("Managed project secret is too large.");
|
|
29
|
+
value = JSON.parse(bytes.subarray(0, bytesRead).toString("utf8"));
|
|
30
|
+
}
|
|
31
|
+
finally {
|
|
32
|
+
await handle.close();
|
|
33
|
+
}
|
|
34
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
35
|
+
throw new Error("Invalid managed project secret.");
|
|
36
|
+
const secret = value;
|
|
37
|
+
if (secret.version !== 1 || secret.generation < 1)
|
|
38
|
+
throw new Error("Unsupported managed project secret version or generation.");
|
|
39
|
+
validateProjectRuntimeRecord({
|
|
40
|
+
id: secret.projectId,
|
|
41
|
+
ownerId: secret.ownerId,
|
|
42
|
+
generation: secret.generation,
|
|
43
|
+
runtimeUsername: secret.username,
|
|
44
|
+
runtimePassword: secret.password,
|
|
45
|
+
});
|
|
46
|
+
validateProjectPublicOrigin(secret.publicOrigin);
|
|
47
|
+
if (secret.publicPathPrefix !== `/p/${secret.projectId}`)
|
|
48
|
+
throw new Error("Invalid managed project public path.");
|
|
49
|
+
return secret;
|
|
50
|
+
}
|
|
51
|
+
/** Initialize this project's account and invalidate every grant from a previous process. */
|
|
52
|
+
export async function initializeManagedProjectAuth(secret, dataDir) {
|
|
53
|
+
const auth = new HubAuthStore({ dataDir });
|
|
54
|
+
const bootstrap = auth.initialize();
|
|
55
|
+
if (!auth.isInitialized()) {
|
|
56
|
+
if (!bootstrap)
|
|
57
|
+
throw new Error("Managed project account initialization is incomplete.");
|
|
58
|
+
await auth.setup({
|
|
59
|
+
username: secret.username,
|
|
60
|
+
password: secret.password,
|
|
61
|
+
token: bootstrap,
|
|
62
|
+
deviceName: "Project runtime",
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
else {
|
|
66
|
+
// A persistent volume must still belong to the credential issued for this
|
|
67
|
+
// project. A mismatch never replaces its account or opens public setup.
|
|
68
|
+
await auth.login({
|
|
69
|
+
username: secret.username,
|
|
70
|
+
password: secret.password,
|
|
71
|
+
deviceName: "Project runtime startup",
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
for (const session of auth.listSessions())
|
|
75
|
+
auth.revoke(session.id);
|
|
76
|
+
}
|
|
77
|
+
export function managedProjectServerOptions(secret) {
|
|
78
|
+
const staticRootDir = resolveWebAppRoot();
|
|
79
|
+
if (!staticRootDir)
|
|
80
|
+
throw new Error("Managed project image is missing its Web application build.");
|
|
81
|
+
return {
|
|
82
|
+
host: "0.0.0.0",
|
|
83
|
+
port: 8790,
|
|
84
|
+
cwd: "/workspace",
|
|
85
|
+
dataDir: "/data",
|
|
86
|
+
authMode: "hub",
|
|
87
|
+
publicOrigin: secret.publicOrigin,
|
|
88
|
+
publicPathPrefix: secret.publicPathPrefix,
|
|
89
|
+
workerEntryPath: resolveWorkerEntry(),
|
|
90
|
+
workerCapabilityModules: resolveWorkerCapabilityModules(),
|
|
91
|
+
staticRootDir,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
export async function runManagedProjectEntry(argv = process.argv.slice(2)) {
|
|
95
|
+
if (argv.length !== 2 || argv[0] !== "--secret" || !argv[1])
|
|
96
|
+
throw new Error("Managed project entry requires one --secret file.");
|
|
97
|
+
const secret = await readManagedProjectSecret(argv[1]);
|
|
98
|
+
await initializeManagedProjectAuth(secret, "/data");
|
|
99
|
+
const server = await startHeadlessServer(managedProjectServerOptions(secret));
|
|
100
|
+
// The provider reads /health; credentials and setup grants never enter logs.
|
|
101
|
+
console.log(`Managed project ${secret.projectId} generation ${secret.generation} is ready.`);
|
|
102
|
+
let closing = false;
|
|
103
|
+
const shutdown = () => {
|
|
104
|
+
if (closing)
|
|
105
|
+
return;
|
|
106
|
+
closing = true;
|
|
107
|
+
const deadline = setTimeout(() => process.exit(1), 15000);
|
|
108
|
+
deadline.unref();
|
|
109
|
+
void server.close().then(() => process.exit(0), () => process.exit(1));
|
|
110
|
+
};
|
|
111
|
+
process.on("SIGTERM", shutdown);
|
|
112
|
+
process.on("SIGINT", shutdown);
|
|
113
|
+
}
|