@cjhyy/code-shell-server 0.9.22 → 0.9.23
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 +45 -1
- package/dist/bin/code-shell-settings-recovery.d.ts +2 -0
- package/dist/bin/code-shell-settings-recovery.js +4 -0
- package/dist/desktop-web/http-api.js +14 -0
- package/dist/environment-identity.d.ts +4 -0
- package/dist/environment-identity.js +67 -0
- package/dist/hub/auth-store.d.ts +2 -0
- package/dist/hub/auth-store.js +7 -0
- package/dist/index.auth.d.ts +3 -0
- package/dist/index.auth.js +2 -0
- package/dist/index.links.d.ts +1 -0
- package/dist/index.links.js +1 -0
- package/dist/index.panels.d.ts +7 -0
- package/dist/index.panels.js +7 -0
- package/dist/index.remote-relay.d.ts +5 -0
- package/dist/index.remote-relay.js +6 -0
- package/dist/index.serve.d.ts +2 -0
- package/dist/index.serve.js +2 -0
- package/dist/links/http.d.ts +3 -0
- package/dist/links/http.js +11 -2
- package/dist/links/remote-configuration.d.ts +4 -0
- package/dist/links/remote-configuration.js +32 -0
- package/dist/links/service.d.ts +6 -1
- package/dist/links/service.js +241 -19
- package/dist/mobile-remote/mobile-upload-service.d.ts +4 -0
- package/dist/mobile-remote/mobile-upload-service.js +69 -11
- package/dist/mobile-remote/remote-host-manager.d.ts +10 -5
- package/dist/mobile-remote/remote-host-manager.js +53 -5
- package/dist/panels/agent-task-service.js +9 -2
- package/dist/panels/automations.d.ts +49 -0
- package/dist/panels/automations.js +134 -0
- package/dist/panels/bridge-contract.d.ts +5 -3
- package/dist/panels/bridge-contract.js +11 -1
- package/dist/panels/directory-bookmarks.d.ts +9 -1
- package/dist/panels/directory-bookmarks.js +64 -5
- package/dist/panels/execution-gate.d.ts +26 -0
- package/dist/panels/execution-gate.js +70 -0
- package/dist/panels/http.d.ts +26 -2
- package/dist/panels/http.js +7 -1
- package/dist/panels/hub-automations.d.ts +31 -0
- package/dist/panels/hub-automations.js +352 -0
- package/dist/panels/management-http.d.ts +20 -2
- package/dist/panels/management-http.js +15 -2
- package/dist/panels/management.d.ts +26 -4
- package/dist/panels/management.js +382 -39
- package/dist/panels/package-mutation.d.ts +8 -0
- package/dist/panels/package-mutation.js +23 -0
- package/dist/panels/process-service.d.ts +3 -0
- package/dist/panels/process-service.js +77 -4
- package/dist/panels/resources/http.d.ts +11 -0
- package/dist/panels/resources/http.js +74 -0
- package/dist/panels/resources/references.d.ts +5 -0
- package/dist/panels/resources/references.js +26 -0
- package/dist/panels/runtime-services.js +16 -3
- package/dist/panels/runtime.d.ts +16 -4
- package/dist/panels/runtime.js +522 -64
- package/dist/panels/shared-tool-jobs.d.ts +40 -0
- package/dist/panels/shared-tool-jobs.js +37 -0
- package/dist/panels/storage-store.d.ts +21 -0
- package/dist/panels/storage-store.js +43 -0
- package/dist/panels/task-cookie-host.d.ts +26 -0
- package/dist/panels/task-cookie-host.js +145 -0
- package/dist/panels/task-cookies.d.ts +51 -0
- package/dist/panels/task-cookies.js +183 -0
- package/dist/panels/tool-executor.d.ts +21 -5
- package/dist/panels/tool-executor.js +171 -30
- package/dist/panels/tool-jobs-storage.d.ts +2 -0
- package/dist/panels/tool-jobs-storage.js +34 -0
- package/dist/panels/tool-jobs.d.ts +49 -3
- package/dist/panels/tool-jobs.js +270 -15
- package/dist/panels/types.d.ts +43 -0
- package/dist/project-runtime/backup.d.ts +26 -0
- package/dist/project-runtime/backup.js +500 -0
- package/dist/project-runtime/control-server.d.ts +4 -0
- package/dist/project-runtime/control-server.js +19 -1
- package/dist/project-runtime/docker-provider.d.ts +4 -0
- package/dist/project-runtime/docker-provider.js +16 -1
- package/dist/project-runtime/managed-entry.js +4 -0
- package/dist/project-runtime/registry.d.ts +2 -0
- package/dist/project-runtime/registry.js +15 -0
- package/dist/project-runtime/seccomp.d.ts +8 -0
- package/dist/project-runtime/seccomp.js +72 -0
- package/dist/project-runtime/types.d.ts +2 -0
- package/dist/remote-relay/byte-stream.d.ts +9 -0
- package/dist/remote-relay/byte-stream.js +71 -0
- package/dist/remote-relay/connector.d.ts +27 -0
- package/dist/remote-relay/connector.js +197 -0
- package/dist/remote-relay/local-stream.d.ts +10 -0
- package/dist/remote-relay/local-stream.js +120 -0
- package/dist/remote-relay/protocol.d.ts +39 -0
- package/dist/remote-relay/protocol.js +64 -0
- package/dist/serve/automation-worker.d.ts +16 -0
- package/dist/serve/automation-worker.js +126 -0
- package/dist/serve/cli.d.ts +3 -0
- package/dist/serve/cli.js +17 -0
- package/dist/serve/headless-server.d.ts +3 -0
- package/dist/serve/headless-server.js +140 -10
- package/dist/serve/settings-recovery-cli.d.ts +29 -0
- package/dist/serve/settings-recovery-cli.js +128 -0
- package/dist/session-http-responses.d.ts +7 -0
- package/dist/session-http-responses.js +40 -0
- package/dist/worker-bridge-core.d.ts +5 -0
- package/dist/worker-bridge-core.js +23 -0
- package/package.json +15 -6
|
@@ -0,0 +1,500 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { execFile, spawn } from "node:child_process";
|
|
3
|
+
import { constants, createWriteStream } from "node:fs";
|
|
4
|
+
import { lstat, mkdir, open, readdir, realpath, rm, writeFile } from "node:fs/promises";
|
|
5
|
+
import { dirname, join, relative, resolve, sep } from "node:path";
|
|
6
|
+
import { pipeline } from "node:stream/promises";
|
|
7
|
+
import { Transform } from "node:stream";
|
|
8
|
+
import { HubAuthStore } from "../hub/auth-store.js";
|
|
9
|
+
import { ProjectRegistry } from "./registry.js";
|
|
10
|
+
const LABEL = "io.codeshell.project-runtime";
|
|
11
|
+
const FORMAT = "codeshell.project-installation-backup";
|
|
12
|
+
const MAX_CONTROL_FILE = 16 * 1024 * 1024;
|
|
13
|
+
const SHA = /^[a-f0-9]{64}$/;
|
|
14
|
+
const UUID = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/;
|
|
15
|
+
const digest = (value) => ({
|
|
16
|
+
sha256: createHash("sha256").update(value).digest("hex"),
|
|
17
|
+
bytes: value.length,
|
|
18
|
+
});
|
|
19
|
+
function imageId(image) {
|
|
20
|
+
if (!/^sha256:[a-f0-9]{64}$/.test(image))
|
|
21
|
+
throw new Error("Backup helper must be a local immutable Docker image ID (sha256:...).");
|
|
22
|
+
}
|
|
23
|
+
async function regular(path, maximum) {
|
|
24
|
+
const before = await lstat(path);
|
|
25
|
+
if (!before.isFile() || before.isSymbolicLink() || before.nlink !== 1 || before.size > maximum)
|
|
26
|
+
throw new Error("Backup metadata must be a bounded regular file without links.");
|
|
27
|
+
const file = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
28
|
+
try {
|
|
29
|
+
const after = await file.stat();
|
|
30
|
+
if (before.ino !== after.ino || before.dev !== after.dev || after.size > maximum)
|
|
31
|
+
throw new Error("Backup file changed while opening.");
|
|
32
|
+
const bytes = Buffer.alloc(after.size + 1);
|
|
33
|
+
let count = 0;
|
|
34
|
+
while (count < bytes.length) {
|
|
35
|
+
const result = await file.read(bytes, count, bytes.length - count, count);
|
|
36
|
+
if (!result.bytesRead)
|
|
37
|
+
break;
|
|
38
|
+
count += result.bytesRead;
|
|
39
|
+
}
|
|
40
|
+
if (count !== after.size)
|
|
41
|
+
throw new Error("Backup file changed while reading.");
|
|
42
|
+
return bytes.subarray(0, count);
|
|
43
|
+
}
|
|
44
|
+
finally {
|
|
45
|
+
await file.close();
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
async function directory(path) {
|
|
49
|
+
const metadata = await lstat(path);
|
|
50
|
+
if (!metadata.isDirectory() || metadata.isSymbolicLink())
|
|
51
|
+
throw new Error("Expected an ordinary directory.");
|
|
52
|
+
}
|
|
53
|
+
async function exclusiveDestination(source, destination) {
|
|
54
|
+
const src = await realpath(source);
|
|
55
|
+
const parent = await realpath(dirname(destination));
|
|
56
|
+
const target = join(parent, relative(dirname(destination), destination));
|
|
57
|
+
const rel = relative(src, target);
|
|
58
|
+
if (!rel || (!rel.startsWith(`..${sep}`) && rel !== ".." && !rel.startsWith(sep)))
|
|
59
|
+
throw new Error("Backup/restore destination must be outside the source directory.");
|
|
60
|
+
await mkdir(target, { mode: 0o700 }); // Never replace an existing directory.
|
|
61
|
+
return target;
|
|
62
|
+
}
|
|
63
|
+
function docker(args) {
|
|
64
|
+
return new Promise((yes, no) => {
|
|
65
|
+
execFile("docker", args, { encoding: "utf8", timeout: 30_000, maxBuffer: 4 * 1024 * 1024 }, (error, stdout) => {
|
|
66
|
+
if (error)
|
|
67
|
+
no(new Error(`Docker ${args[0]} operation failed.`));
|
|
68
|
+
else
|
|
69
|
+
yes(stdout.trim());
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
async function inspect(kind, name) {
|
|
74
|
+
// Distinguish absence from daemon/permission errors through a successful inventory.
|
|
75
|
+
const names = (await docker([
|
|
76
|
+
kind === "container" ? "ps" : "volume",
|
|
77
|
+
...(kind === "container" ? ["-a"] : ["ls"]),
|
|
78
|
+
"--format",
|
|
79
|
+
kind === "container" ? "{{.Names}}" : "{{.Name}}",
|
|
80
|
+
])).split("\n");
|
|
81
|
+
if (!names.includes(name))
|
|
82
|
+
return null;
|
|
83
|
+
const rows = JSON.parse(await docker([kind, "inspect", name]));
|
|
84
|
+
if (!Array.isArray(rows) || rows.length !== 1)
|
|
85
|
+
throw new Error("Invalid Docker inventory.");
|
|
86
|
+
return rows[0];
|
|
87
|
+
}
|
|
88
|
+
function labels(installation, project) {
|
|
89
|
+
return {
|
|
90
|
+
[`${LABEL}.installation`]: installation,
|
|
91
|
+
[`${LABEL}.project`]: project.id,
|
|
92
|
+
[`${LABEL}.owner`]: project.ownerId,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
function owns(row, expected) {
|
|
96
|
+
const actual = row?.Config?.Labels ?? row?.Labels;
|
|
97
|
+
if (!actual || Object.entries(expected).some(([key, value]) => actual[key] !== value))
|
|
98
|
+
throw new Error("Docker resource does not belong to the expected installation and project.");
|
|
99
|
+
}
|
|
100
|
+
const baseName = (installation, projectId) => `codeshell-${installation}-${projectId}`;
|
|
101
|
+
async function stopped(installation, projects) {
|
|
102
|
+
for (const project of projects) {
|
|
103
|
+
const row = await inspect("container", baseName(installation, project.id));
|
|
104
|
+
if (!row)
|
|
105
|
+
continue;
|
|
106
|
+
owns(row, labels(installation, project));
|
|
107
|
+
if (row.State?.Running || row.State?.Restarting || row.State?.Paused)
|
|
108
|
+
throw new Error("Stop every project container before backing up.");
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
async function archive(volume, file, helperImage, restore, expected) {
|
|
112
|
+
const id = randomUUID();
|
|
113
|
+
const name = `codeshell-backup-${id}`;
|
|
114
|
+
const argv = [
|
|
115
|
+
"container",
|
|
116
|
+
"create",
|
|
117
|
+
"--name",
|
|
118
|
+
name,
|
|
119
|
+
"--label",
|
|
120
|
+
`io.codeshell.backup=${id}`,
|
|
121
|
+
"--pull=never",
|
|
122
|
+
"--network=none",
|
|
123
|
+
"--read-only",
|
|
124
|
+
"--user",
|
|
125
|
+
"0:0",
|
|
126
|
+
"--cap-drop=ALL",
|
|
127
|
+
"--cap-add=DAC_OVERRIDE",
|
|
128
|
+
"--security-opt=no-new-privileges:true",
|
|
129
|
+
"--memory=256m",
|
|
130
|
+
"--pids-limit=32",
|
|
131
|
+
"--mount",
|
|
132
|
+
`type=volume,source=${volume},target=/volume${restore ? "" : ",readonly"}`,
|
|
133
|
+
"--entrypoint",
|
|
134
|
+
"tar",
|
|
135
|
+
];
|
|
136
|
+
if (restore)
|
|
137
|
+
argv.push("--cap-add=CHOWN", "--cap-add=FOWNER", "-i");
|
|
138
|
+
argv.push(helperImage, restore ? "-xpf" : "-cpf", "-", "-C", "/volume");
|
|
139
|
+
if (!restore)
|
|
140
|
+
argv.push(".");
|
|
141
|
+
let container;
|
|
142
|
+
try {
|
|
143
|
+
container = await docker(argv);
|
|
144
|
+
if (!/^[a-f0-9]{64}$/.test(container))
|
|
145
|
+
throw new Error("Invalid backup container identity.");
|
|
146
|
+
const child = spawn("docker", ["start", "-a", ...(restore ? ["-i"] : []), container], {
|
|
147
|
+
stdio: [restore ? "pipe" : "ignore", "pipe", "ignore"],
|
|
148
|
+
});
|
|
149
|
+
const timer = setTimeout(() => child.kill("SIGKILL"), 30 * 60_000);
|
|
150
|
+
const finished = new Promise((yes, no) => {
|
|
151
|
+
child.once("error", no);
|
|
152
|
+
child.once("close", (code) => code === 0 ? yes() : no(new Error("Volume archive operation failed.")));
|
|
153
|
+
});
|
|
154
|
+
// Opening the archive can fail before Promise.allSettled attaches its handlers.
|
|
155
|
+
void finished.catch(() => undefined);
|
|
156
|
+
if (restore)
|
|
157
|
+
child.stdout?.resume();
|
|
158
|
+
let input;
|
|
159
|
+
const hash = createHash("sha256");
|
|
160
|
+
let bytes = 0;
|
|
161
|
+
const hashing = new Transform({
|
|
162
|
+
transform(chunk, _encoding, callback) {
|
|
163
|
+
bytes += chunk.length;
|
|
164
|
+
hash.update(chunk);
|
|
165
|
+
callback(null, chunk);
|
|
166
|
+
},
|
|
167
|
+
});
|
|
168
|
+
try {
|
|
169
|
+
if (restore)
|
|
170
|
+
input = await open(file, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
171
|
+
const transfer = restore
|
|
172
|
+
? pipeline(input.createReadStream(), hashing, child.stdin)
|
|
173
|
+
: pipeline(child.stdout, createWriteStream(file, { flags: "wx", mode: 0o600, flush: true }));
|
|
174
|
+
const results = await Promise.allSettled([
|
|
175
|
+
transfer.catch((error) => {
|
|
176
|
+
child.kill("SIGKILL");
|
|
177
|
+
throw error;
|
|
178
|
+
}),
|
|
179
|
+
finished,
|
|
180
|
+
]);
|
|
181
|
+
for (const result of results)
|
|
182
|
+
if (result.status === "rejected")
|
|
183
|
+
throw result.reason;
|
|
184
|
+
if (restore && expected)
|
|
185
|
+
same({ sha256: hash.digest("hex"), bytes }, expected);
|
|
186
|
+
}
|
|
187
|
+
finally {
|
|
188
|
+
clearTimeout(timer);
|
|
189
|
+
child.kill("SIGKILL");
|
|
190
|
+
await input?.close();
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
finally {
|
|
194
|
+
const row = await inspect("container", name);
|
|
195
|
+
if (row) {
|
|
196
|
+
owns(row, { "io.codeshell.backup": id });
|
|
197
|
+
await docker(["rm", "-f", row.Id]);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
async function fileDigest(path) {
|
|
202
|
+
const metadata = await lstat(path);
|
|
203
|
+
if (!metadata.isFile() || metadata.isSymbolicLink() || metadata.nlink !== 1)
|
|
204
|
+
throw new Error("Archive must be a regular file without links.");
|
|
205
|
+
const hash = createHash("sha256");
|
|
206
|
+
let bytes = 0;
|
|
207
|
+
const file = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
208
|
+
try {
|
|
209
|
+
const opened = await file.stat();
|
|
210
|
+
if (metadata.ino !== opened.ino || metadata.dev !== opened.dev || opened.nlink !== 1)
|
|
211
|
+
throw new Error("Archive changed while opening.");
|
|
212
|
+
for await (const chunk of file.createReadStream()) {
|
|
213
|
+
bytes += chunk.length;
|
|
214
|
+
hash.update(chunk);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
finally {
|
|
218
|
+
await file.close();
|
|
219
|
+
}
|
|
220
|
+
return { sha256: hash.digest("hex"), bytes };
|
|
221
|
+
}
|
|
222
|
+
function same(actual, expected) {
|
|
223
|
+
if (actual.sha256 !== expected.sha256 || actual.bytes !== expected.bytes)
|
|
224
|
+
throw new Error("Backup checksum verification failed.");
|
|
225
|
+
}
|
|
226
|
+
async function controlBytes(root, path) {
|
|
227
|
+
const parts = path.split("/");
|
|
228
|
+
let parent = root;
|
|
229
|
+
await directory(parent);
|
|
230
|
+
for (const part of parts.slice(0, -1)) {
|
|
231
|
+
parent = join(parent, part);
|
|
232
|
+
await directory(parent);
|
|
233
|
+
}
|
|
234
|
+
return regular(join(root, path), MAX_CONTROL_FILE);
|
|
235
|
+
}
|
|
236
|
+
async function copyControl(source, destination, records, prefix = "") {
|
|
237
|
+
await directory(source);
|
|
238
|
+
for (const name of await readdir(source)) {
|
|
239
|
+
const key = prefix ? `${prefix}/${name}` : name;
|
|
240
|
+
if (["project-control.lock", "project-runtime-secrets", "administrator-setup.txt"].includes(key))
|
|
241
|
+
continue;
|
|
242
|
+
if (key === "restore-in-progress")
|
|
243
|
+
throw new Error("Cannot back up an incomplete restore.");
|
|
244
|
+
if (Object.keys(records).length >= 4096)
|
|
245
|
+
throw new Error("Control directory contains too many files.");
|
|
246
|
+
const src = join(source, name), dst = join(destination, name);
|
|
247
|
+
const metadata = await lstat(src);
|
|
248
|
+
if (metadata.isDirectory() && !metadata.isSymbolicLink()) {
|
|
249
|
+
await mkdir(dst, { mode: 0o700 });
|
|
250
|
+
await copyControl(src, dst, records, key);
|
|
251
|
+
}
|
|
252
|
+
else {
|
|
253
|
+
const bytes = await regular(src, MAX_CONTROL_FILE);
|
|
254
|
+
await writeFile(dst, bytes, { mode: 0o600, flag: "wx", flush: true });
|
|
255
|
+
records[key] = digest(bytes);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
/** Offline, owner-only backup. It never stops projects or modifies their volumes. */
|
|
260
|
+
export async function backupProjectInstallation(options) {
|
|
261
|
+
imageId(options.helperImage);
|
|
262
|
+
const source = resolve(options.dataDir);
|
|
263
|
+
await directory(source);
|
|
264
|
+
// Do not initialize a fresh registry as a side effect of a mistaken backup path.
|
|
265
|
+
await regular(join(source, "project-control/registry.json"), 512 * 1024);
|
|
266
|
+
const registry = new ProjectRegistry(source);
|
|
267
|
+
let destination;
|
|
268
|
+
try {
|
|
269
|
+
const rows = registry.all();
|
|
270
|
+
if (rows.some((row) => row.status !== "stopped"))
|
|
271
|
+
throw new Error("Stop all projects and the controller before backup.");
|
|
272
|
+
const auth = new HubAuthStore({ dataDir: source });
|
|
273
|
+
if (!auth.isInitialized())
|
|
274
|
+
throw new Error("Complete administrator setup before backing up.");
|
|
275
|
+
const installationId = registry.installationId;
|
|
276
|
+
const projects = rows.map(({ id, ownerId, generation }) => ({ id, ownerId, generation }));
|
|
277
|
+
await stopped(installationId, projects);
|
|
278
|
+
await docker(["image", "inspect", options.helperImage]);
|
|
279
|
+
destination = await exclusiveDestination(source, resolve(options.destination));
|
|
280
|
+
await mkdir(join(destination, "control"), { mode: 0o700 });
|
|
281
|
+
await mkdir(join(destination, "volumes"), { mode: 0o700 });
|
|
282
|
+
const manifest = {
|
|
283
|
+
format: FORMAT,
|
|
284
|
+
version: 1,
|
|
285
|
+
createdAt: new Date().toISOString(),
|
|
286
|
+
installationId,
|
|
287
|
+
helperImage: options.helperImage,
|
|
288
|
+
projects,
|
|
289
|
+
control: Object.create(null),
|
|
290
|
+
volumes: [],
|
|
291
|
+
};
|
|
292
|
+
await copyControl(source, join(destination, "control"), manifest.control);
|
|
293
|
+
for (const project of projects) {
|
|
294
|
+
let found = 0;
|
|
295
|
+
for (const kind of ["data", "workspace"]) {
|
|
296
|
+
const volume = `${baseName(installationId, project.id)}-${kind}`;
|
|
297
|
+
const row = await inspect("volume", volume);
|
|
298
|
+
if (!row)
|
|
299
|
+
continue;
|
|
300
|
+
owns(row, labels(installationId, project));
|
|
301
|
+
const users = await docker(["ps", "-q", "--filter", `volume=${volume}`]);
|
|
302
|
+
if (users)
|
|
303
|
+
throw new Error("A running container still uses a project volume.");
|
|
304
|
+
const file = `volumes/${project.id}-${kind}.tar`;
|
|
305
|
+
await archive(volume, join(destination, file), options.helperImage, false);
|
|
306
|
+
manifest.volumes.push({
|
|
307
|
+
projectId: project.id,
|
|
308
|
+
kind,
|
|
309
|
+
file,
|
|
310
|
+
digest: await fileDigest(join(destination, file)),
|
|
311
|
+
});
|
|
312
|
+
found++;
|
|
313
|
+
}
|
|
314
|
+
if (found !== 2 && !(found === 0 && project.generation === 0))
|
|
315
|
+
throw new Error("Project volume set is incomplete.");
|
|
316
|
+
}
|
|
317
|
+
await stopped(installationId, projects);
|
|
318
|
+
registry.all(); // Recheck registry ownership and unchanged bytes after asynchronous work.
|
|
319
|
+
const manifestBytes = JSON.stringify(manifest, null, 2) + "\n";
|
|
320
|
+
if (Buffer.byteLength(manifestBytes) > 1024 * 1024)
|
|
321
|
+
throw new Error("Backup manifest exceeds its supported size.");
|
|
322
|
+
await writeFile(join(destination, "manifest.json"), manifestBytes, {
|
|
323
|
+
mode: 0o600,
|
|
324
|
+
flag: "wx",
|
|
325
|
+
flush: true,
|
|
326
|
+
});
|
|
327
|
+
return {
|
|
328
|
+
destination,
|
|
329
|
+
projects: projects.length,
|
|
330
|
+
volumes: manifest.volumes.length,
|
|
331
|
+
installationId,
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
catch (error) {
|
|
335
|
+
if (destination)
|
|
336
|
+
await rm(destination, { recursive: true, force: true });
|
|
337
|
+
throw error;
|
|
338
|
+
}
|
|
339
|
+
finally {
|
|
340
|
+
registry.close();
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
function validateManifest(value) {
|
|
344
|
+
if (value?.format !== FORMAT ||
|
|
345
|
+
value.version !== 1 ||
|
|
346
|
+
!UUID.test(value.installationId) ||
|
|
347
|
+
!Array.isArray(value.projects) ||
|
|
348
|
+
value.projects.length > 128 ||
|
|
349
|
+
!Array.isArray(value.volumes) ||
|
|
350
|
+
value.volumes.length > 256 ||
|
|
351
|
+
!value.control ||
|
|
352
|
+
typeof value.control !== "object" ||
|
|
353
|
+
Array.isArray(value.control))
|
|
354
|
+
throw new Error("Unsupported or invalid Cloud backup manifest.");
|
|
355
|
+
const ids = new Set();
|
|
356
|
+
for (const p of value.projects) {
|
|
357
|
+
if (!p ||
|
|
358
|
+
!UUID.test(p.id) ||
|
|
359
|
+
!UUID.test(p.ownerId) ||
|
|
360
|
+
ids.has(p.id) ||
|
|
361
|
+
!Number.isSafeInteger(p.generation) ||
|
|
362
|
+
p.generation < 0)
|
|
363
|
+
throw new Error("Invalid backup project identity.");
|
|
364
|
+
ids.add(p.id);
|
|
365
|
+
}
|
|
366
|
+
const paths = new Set();
|
|
367
|
+
for (const item of value.volumes) {
|
|
368
|
+
if (!item ||
|
|
369
|
+
!ids.has(item.projectId) ||
|
|
370
|
+
!["data", "workspace"].includes(item.kind) ||
|
|
371
|
+
item.file !== `volumes/${item.projectId}-${item.kind}.tar` ||
|
|
372
|
+
paths.has(item.file))
|
|
373
|
+
throw new Error("Invalid backup volume inventory.");
|
|
374
|
+
paths.add(item.file);
|
|
375
|
+
}
|
|
376
|
+
if (Object.keys(value.control).length > 4096)
|
|
377
|
+
throw new Error("Invalid control inventory.");
|
|
378
|
+
for (const [path, entry] of Object.entries(value.control)) {
|
|
379
|
+
if (!path ||
|
|
380
|
+
path.includes("\\") ||
|
|
381
|
+
path.split("/").some((part) => !part || part === "." || part === "..") ||
|
|
382
|
+
/[\0\r\n]/.test(path) ||
|
|
383
|
+
path === "restore-in-progress" ||
|
|
384
|
+
path.startsWith("project-control.lock/") ||
|
|
385
|
+
path === "project-control.lock" ||
|
|
386
|
+
!entry ||
|
|
387
|
+
!SHA.test(entry.sha256) ||
|
|
388
|
+
!Number.isSafeInteger(entry.bytes) ||
|
|
389
|
+
entry.bytes < 0 ||
|
|
390
|
+
entry.bytes > MAX_CONTROL_FILE)
|
|
391
|
+
throw new Error("Invalid backup control entry.");
|
|
392
|
+
}
|
|
393
|
+
for (const item of value.volumes)
|
|
394
|
+
if (!item.digest ||
|
|
395
|
+
!SHA.test(item.digest.sha256) ||
|
|
396
|
+
!Number.isSafeInteger(item.digest.bytes) ||
|
|
397
|
+
item.digest.bytes < 0)
|
|
398
|
+
throw new Error("Invalid backup archive digest.");
|
|
399
|
+
for (const project of value.projects) {
|
|
400
|
+
const count = value.volumes.filter((v) => v.projectId === project.id).length;
|
|
401
|
+
if (count !== 2 && !(count === 0 && project.generation === 0))
|
|
402
|
+
throw new Error("Incomplete backup volume set.");
|
|
403
|
+
}
|
|
404
|
+
for (const path of [
|
|
405
|
+
"project-control/registry.json",
|
|
406
|
+
"project-control/initialized",
|
|
407
|
+
"hub/auth.json",
|
|
408
|
+
])
|
|
409
|
+
if (!Object.hasOwn(value.control, path))
|
|
410
|
+
throw new Error("Backup lacks required control data.");
|
|
411
|
+
}
|
|
412
|
+
/** Restore into a new directory and new Docker volumes. Original installation is untouched. */
|
|
413
|
+
export async function restoreProjectInstallation(options) {
|
|
414
|
+
imageId(options.helperImage);
|
|
415
|
+
const source = resolve(options.source);
|
|
416
|
+
await directory(source);
|
|
417
|
+
await directory(join(source, "control"));
|
|
418
|
+
await directory(join(source, "volumes"));
|
|
419
|
+
const manifest = JSON.parse((await regular(join(source, "manifest.json"), 1024 * 1024)).toString());
|
|
420
|
+
validateManifest(manifest);
|
|
421
|
+
// Verify every archive before creating a destination or Docker resource.
|
|
422
|
+
for (const volume of manifest.volumes)
|
|
423
|
+
same(await fileDigest(join(source, volume.file)), volume.digest);
|
|
424
|
+
for (const [path, expected] of Object.entries(manifest.control))
|
|
425
|
+
same(digest(await controlBytes(join(source, "control"), path)), expected);
|
|
426
|
+
await docker(["image", "inspect", options.helperImage]);
|
|
427
|
+
const destination = await exclusiveDestination(source, resolve(options.destination));
|
|
428
|
+
const created = [];
|
|
429
|
+
let registry;
|
|
430
|
+
try {
|
|
431
|
+
await writeFile(join(destination, "restore-in-progress"), "1\n", {
|
|
432
|
+
mode: 0o600,
|
|
433
|
+
flag: "wx",
|
|
434
|
+
flush: true,
|
|
435
|
+
});
|
|
436
|
+
for (const [path, expected] of Object.entries(manifest.control)) {
|
|
437
|
+
const bytes = await controlBytes(join(source, "control"), path);
|
|
438
|
+
same(digest(bytes), expected);
|
|
439
|
+
await mkdir(dirname(join(destination, path)), { recursive: true, mode: 0o700 });
|
|
440
|
+
await writeFile(join(destination, path), bytes, { mode: 0o600, flag: "wx", flush: true });
|
|
441
|
+
}
|
|
442
|
+
registry = new ProjectRegistry(destination);
|
|
443
|
+
const rows = registry.all();
|
|
444
|
+
if (registry.installationId !== manifest.installationId ||
|
|
445
|
+
rows.some((p) => p.status !== "stopped") ||
|
|
446
|
+
JSON.stringify(rows.map(({ id, ownerId, generation }) => ({ id, ownerId, generation }))) !==
|
|
447
|
+
JSON.stringify(manifest.projects))
|
|
448
|
+
throw new Error("Backup control and volume identities disagree.");
|
|
449
|
+
const auth = new HubAuthStore({ dataDir: destination });
|
|
450
|
+
if (!auth.isInitialized())
|
|
451
|
+
throw new Error("Backup administrator is not initialized.");
|
|
452
|
+
auth.revokeAllSessions();
|
|
453
|
+
registry.renewInstallationForRestore();
|
|
454
|
+
const installationId = registry.installationId;
|
|
455
|
+
for (const volume of manifest.volumes) {
|
|
456
|
+
const project = manifest.projects.find((p) => p.id === volume.projectId);
|
|
457
|
+
const name = `${baseName(installationId, project.id)}-${volume.kind}`;
|
|
458
|
+
const expected = labels(installationId, project);
|
|
459
|
+
if (await inspect("volume", name))
|
|
460
|
+
throw new Error("Restore volume already exists; refusing to overwrite.");
|
|
461
|
+
await docker([
|
|
462
|
+
"volume",
|
|
463
|
+
"create",
|
|
464
|
+
...Object.entries(expected).flatMap(([key, value]) => ["--label", `${key}=${value}`]),
|
|
465
|
+
name,
|
|
466
|
+
]);
|
|
467
|
+
created.push({ name, expected });
|
|
468
|
+
owns(await inspect("volume", name), expected);
|
|
469
|
+
await archive(name, join(source, volume.file), options.helperImage, true, volume.digest);
|
|
470
|
+
}
|
|
471
|
+
// Runtime secrets are generated afresh; old bootstrap URLs and environment identities are not reused.
|
|
472
|
+
for (const path of ["project-runtime-secrets", "administrator-setup.txt", "environment.json"])
|
|
473
|
+
await rm(join(destination, path), { recursive: true, force: true });
|
|
474
|
+
registry.all();
|
|
475
|
+
await rm(join(destination, "restore-in-progress"));
|
|
476
|
+
return {
|
|
477
|
+
destination,
|
|
478
|
+
installationId,
|
|
479
|
+
previousInstallationId: manifest.installationId,
|
|
480
|
+
projects: rows.length,
|
|
481
|
+
volumes: created.length,
|
|
482
|
+
};
|
|
483
|
+
}
|
|
484
|
+
catch (error) {
|
|
485
|
+
// Only delete volumes created by this restore and still carrying its exact labels.
|
|
486
|
+
for (const { name, expected } of created.reverse()) {
|
|
487
|
+
try {
|
|
488
|
+
owns(await inspect("volume", name), expected);
|
|
489
|
+
await docker(["volume", "rm", name]);
|
|
490
|
+
}
|
|
491
|
+
catch {
|
|
492
|
+
/* Preserve a busy/changed volume for operator inspection. */
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
throw error; // Keep the marked offline directory as recovery evidence; never advertise success.
|
|
496
|
+
}
|
|
497
|
+
finally {
|
|
498
|
+
registry?.close();
|
|
499
|
+
}
|
|
500
|
+
}
|
|
@@ -1,11 +1,15 @@
|
|
|
1
|
+
import type { RemoteLinkConfiguration } from "@cjhyy/code-shell-core";
|
|
1
2
|
import type { ProjectRuntimeProvider } from "./types.js";
|
|
2
3
|
export interface ProjectControlServerOptions {
|
|
3
4
|
host: string;
|
|
4
5
|
port: number;
|
|
5
6
|
dataDir: string;
|
|
6
7
|
publicOrigin?: string;
|
|
8
|
+
remoteLink?: RemoteLinkConfiguration;
|
|
7
9
|
staticRootDir?: string;
|
|
8
10
|
runtimeImage?: string;
|
|
11
|
+
/** Trusted operator configuration, never accepted from project requests. */
|
|
12
|
+
runtimeSeccompProfile?: string;
|
|
9
13
|
/** Dependency injection for lifecycle/transport integration tests. */
|
|
10
14
|
provider?: ProjectRuntimeProvider;
|
|
11
15
|
}
|
|
@@ -1,5 +1,8 @@
|
|
|
1
|
+
import { remoteLinkHostConfiguration } from "../links/remote-configuration.js";
|
|
2
|
+
import { describeEnvironment } from "../environment-identity.js";
|
|
1
3
|
import { createServer } from "node:http";
|
|
2
|
-
import { readFileSync } from "node:fs";
|
|
4
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
5
|
+
import { join } from "node:path";
|
|
3
6
|
import { extname } from "node:path";
|
|
4
7
|
import { createHubAuth } from "../hub/auth-http.js";
|
|
5
8
|
import { resolveSafe } from "../mobile-remote/mobile-static.js";
|
|
@@ -65,6 +68,9 @@ async function readBody(request) {
|
|
|
65
68
|
}
|
|
66
69
|
/** A small account/project gateway. It never creates a worker, executes tools or mounts cwd. */
|
|
67
70
|
export async function startProjectControlServer(options) {
|
|
71
|
+
const remoteLink = remoteLinkHostConfiguration(options.remoteLink, options.publicOrigin);
|
|
72
|
+
if (existsSync(join(options.dataDir, "restore-in-progress")))
|
|
73
|
+
throw new Error("Cloud installation restore is incomplete; do not start this directory.");
|
|
68
74
|
const registry = new ProjectRegistry(options.dataDir);
|
|
69
75
|
let proxy;
|
|
70
76
|
let manager;
|
|
@@ -100,6 +106,8 @@ export async function startProjectControlServer(options) {
|
|
|
100
106
|
installationId: registry.installationId,
|
|
101
107
|
dataDir: options.dataDir,
|
|
102
108
|
image: options.runtimeImage,
|
|
109
|
+
seccompProfile: options.runtimeSeccompProfile,
|
|
110
|
+
remoteLink,
|
|
103
111
|
});
|
|
104
112
|
manager = new ProjectManager({
|
|
105
113
|
registry,
|
|
@@ -144,6 +152,16 @@ export async function startProjectControlServer(options) {
|
|
|
144
152
|
return;
|
|
145
153
|
if (await transport.handle(request, response))
|
|
146
154
|
return;
|
|
155
|
+
if (pathname === "/api/v1/environment") {
|
|
156
|
+
if (!auth.isOriginAllowed(request))
|
|
157
|
+
throw new ProjectRegistryError(403, "请求来源无效。");
|
|
158
|
+
if (!(await auth.authenticate(request)))
|
|
159
|
+
throw new ProjectRegistryError(401, "请先登录。");
|
|
160
|
+
if (request.method !== "GET")
|
|
161
|
+
throw new ProjectRegistryError(405, "请求方法无效。");
|
|
162
|
+
json(response, 200, describeEnvironment(registry.installationId, "project-host"));
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
147
165
|
if (pathname === "/api/v1/projects" || pathname.startsWith("/api/v1/projects/")) {
|
|
148
166
|
if (!auth.isOriginAllowed(request))
|
|
149
167
|
throw new ProjectRegistryError(403, "请求来源无效。");
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { RemoteLinkConfiguration } from "@cjhyy/code-shell-core";
|
|
1
2
|
import type { ProjectRuntimeProvider, ProjectRuntimeRecord } from "./types.js";
|
|
2
3
|
export declare const DEFAULT_PROJECT_RUNTIME_IMAGE = "codeshell-project-runtime:local";
|
|
3
4
|
export declare const PROJECT_RUNTIME_SECRET_PATH = "/run/codeshell-project.json";
|
|
@@ -11,6 +12,9 @@ export interface DockerProjectProviderOptions {
|
|
|
11
12
|
installationId: string;
|
|
12
13
|
dataDir: string;
|
|
13
14
|
image?: string;
|
|
15
|
+
/** Administrator-owned seccomp JSON; omitted keeps the Docker default. */
|
|
16
|
+
seccompProfile?: string;
|
|
17
|
+
remoteLink?: RemoteLinkConfiguration;
|
|
14
18
|
limits?: {
|
|
15
19
|
memoryMb?: number;
|
|
16
20
|
cpus?: number;
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { readProjectSeccompProfile, materializeProjectSeccompProfile } from "./seccomp.js";
|
|
2
|
+
import { remoteLinkHostConfiguration } from "../links/remote-configuration.js";
|
|
1
3
|
import { execFile } from "node:child_process";
|
|
2
4
|
import { createHash, randomUUID } from "node:crypto";
|
|
3
5
|
import { chmod, lstat, mkdir, open, rename, rm } from "node:fs/promises";
|
|
@@ -108,6 +110,9 @@ export function createDockerProjectProvider(options) {
|
|
|
108
110
|
const image = options.image ?? DEFAULT_PROJECT_RUNTIME_IMAGE;
|
|
109
111
|
if (!/^[A-Za-z0-9][A-Za-z0-9._/:@-]{0,255}$/.test(image))
|
|
110
112
|
throw new ProjectRuntimeError("Invalid project runtime image.");
|
|
113
|
+
const seccomp = options.seccompProfile === undefined
|
|
114
|
+
? undefined
|
|
115
|
+
: readProjectSeccompProfile(options.seccompProfile);
|
|
111
116
|
const dataDir = resolve(options.dataDir);
|
|
112
117
|
if (/[\0,\r\n]/.test(dataDir))
|
|
113
118
|
throw new ProjectRuntimeError("Unsupported project metadata path.");
|
|
@@ -249,8 +254,13 @@ export function createDockerProjectProvider(options) {
|
|
|
249
254
|
password: project.runtimePassword,
|
|
250
255
|
publicOrigin,
|
|
251
256
|
publicPathPrefix: `/p/${project.id}`,
|
|
257
|
+
...(options.remoteLink
|
|
258
|
+
? { remoteLink: remoteLinkHostConfiguration(options.remoteLink, publicOrigin) }
|
|
259
|
+
: {}),
|
|
252
260
|
};
|
|
253
|
-
const configuration = createHash("sha256")
|
|
261
|
+
const configuration = createHash("sha256")
|
|
262
|
+
.update(JSON.stringify(seccomp ? { secret, seccomp: seccomp.sha256 } : secret))
|
|
263
|
+
.digest("hex");
|
|
254
264
|
const configLabel = `${PROJECT_RUNTIME_LABEL}.configuration`;
|
|
255
265
|
const generationLabel = `${PROJECT_RUNTIME_LABEL}.generation`;
|
|
256
266
|
const imageResult = await invoke(["image", "inspect", image], signal);
|
|
@@ -329,6 +339,11 @@ export function createDockerProjectProvider(options) {
|
|
|
329
339
|
"--entrypoint",
|
|
330
340
|
"node",
|
|
331
341
|
];
|
|
342
|
+
if (seccomp) {
|
|
343
|
+
const path = await materializeProjectSeccompProfile(dataDir, seccomp);
|
|
344
|
+
interrupted(signal);
|
|
345
|
+
args.push("--security-opt", `seccomp=${path}`);
|
|
346
|
+
}
|
|
332
347
|
for (const [key, value] of Object.entries({
|
|
333
348
|
...expected,
|
|
334
349
|
[generationLabel]: String(project.generation),
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { remoteLinkHostConfiguration } from "../links/remote-configuration.js";
|
|
1
2
|
import { constants } from "node:fs";
|
|
2
3
|
import { lstat, open } from "node:fs/promises";
|
|
3
4
|
import { HubAuthStore } from "../hub/auth-store.js";
|
|
@@ -46,6 +47,8 @@ export async function readManagedProjectSecret(file) {
|
|
|
46
47
|
validateProjectPublicOrigin(secret.publicOrigin);
|
|
47
48
|
if (secret.publicPathPrefix !== `/p/${secret.projectId}`)
|
|
48
49
|
throw new Error("Invalid managed project public path.");
|
|
50
|
+
if (secret.remoteLink)
|
|
51
|
+
secret.remoteLink = remoteLinkHostConfiguration(secret.remoteLink, secret.publicOrigin);
|
|
49
52
|
return secret;
|
|
50
53
|
}
|
|
51
54
|
/** Initialize this project's account and invalidate every grant from a previous process. */
|
|
@@ -85,6 +88,7 @@ export function managedProjectServerOptions(secret) {
|
|
|
85
88
|
dataDir: "/data",
|
|
86
89
|
authMode: "hub",
|
|
87
90
|
publicOrigin: secret.publicOrigin,
|
|
91
|
+
remoteLink: remoteLinkHostConfiguration(secret.remoteLink, secret.publicOrigin),
|
|
88
92
|
publicPathPrefix: secret.publicPathPrefix,
|
|
89
93
|
workerEntryPath: resolveWorkerEntry(),
|
|
90
94
|
workerCapabilityModules: resolveWorkerCapabilityModules(),
|
|
@@ -37,6 +37,8 @@ export declare class ProjectRegistry {
|
|
|
37
37
|
});
|
|
38
38
|
/** Release the single-controller lease on shutdown or startup failure. */
|
|
39
39
|
close(): void;
|
|
40
|
+
/** Offline restore only: new Docker namespace, same project credentials for restored data volumes. */
|
|
41
|
+
renewInstallationForRestore(): void;
|
|
40
42
|
get installationId(): string;
|
|
41
43
|
private validate;
|
|
42
44
|
private assertStorage;
|
|
@@ -103,6 +103,21 @@ export class ProjectRegistry {
|
|
|
103
103
|
close() {
|
|
104
104
|
this.controllerLock.release();
|
|
105
105
|
}
|
|
106
|
+
/** Offline restore only: new Docker namespace, same project credentials for restored data volumes. */
|
|
107
|
+
renewInstallationForRestore() {
|
|
108
|
+
if (this.all().some((project) => project.status !== "stopped"))
|
|
109
|
+
throw new Error("Stop all projects before restoring an installation.");
|
|
110
|
+
const next = structuredClone(this.data);
|
|
111
|
+
next.installationId = randomUUID();
|
|
112
|
+
for (const project of next.projects) {
|
|
113
|
+
if (project.generation > 0)
|
|
114
|
+
project.generation += 1;
|
|
115
|
+
project.updatedAt = Math.max(project.updatedAt, this.now());
|
|
116
|
+
delete project.error;
|
|
117
|
+
}
|
|
118
|
+
this.write(next);
|
|
119
|
+
this.data = next;
|
|
120
|
+
}
|
|
106
121
|
get installationId() {
|
|
107
122
|
this.assertStorage();
|
|
108
123
|
return this.data.installationId;
|