@cjhyy/code-shell-server 0.9.37 → 0.9.39
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/dist/hub/uploads.js +3 -1
- package/dist/index.serve.d.ts +1 -0
- package/dist/index.serve.js +1 -0
- package/dist/project-runtime/account-binding.d.ts +6 -0
- package/dist/project-runtime/account-binding.js +9 -0
- package/dist/project-runtime/backup.d.ts +3 -0
- package/dist/project-runtime/backup.js +62 -17
- package/dist/project-runtime/cloud-browser-auth.d.ts +15 -0
- package/dist/project-runtime/cloud-browser-auth.js +352 -0
- package/dist/project-runtime/control-server.d.ts +14 -0
- package/dist/project-runtime/control-server.js +49 -17
- package/dist/project-runtime/docker-provider.d.ts +6 -0
- package/dist/project-runtime/docker-provider.js +10 -5
- package/dist/project-runtime/manager.d.ts +4 -0
- package/dist/project-runtime/manager.js +33 -0
- package/dist/project-runtime/proxy.js +11 -4
- package/dist/project-runtime/registry.d.ts +8 -1
- package/dist/project-runtime/registry.js +139 -10
- package/dist/serve/headless-server.js +14 -0
- package/package.json +5 -5
package/dist/hub/uploads.js
CHANGED
|
@@ -6,6 +6,8 @@ import { markAttachmentsSent, stageFileBytes, stageImageBytes, } from "../attach
|
|
|
6
6
|
const MAX_BYTES = 20 * 1024 * 1024;
|
|
7
7
|
const TTL_MS = 15 * 60 * 1000;
|
|
8
8
|
const SAFE_ID = /^[a-zA-Z0-9_-]{8,80}$/;
|
|
9
|
+
// Match the session/history boundary so existing dotted session IDs can attach files.
|
|
10
|
+
const SAFE_SESSION_ID = /^(?!.*\.\.)[A-Za-z0-9_-][A-Za-z0-9_.-]{0,127}$/;
|
|
9
11
|
const emptyUploads = () => ({
|
|
10
12
|
attachments: [],
|
|
11
13
|
commit: async () => { },
|
|
@@ -150,7 +152,7 @@ export class HubUploads {
|
|
|
150
152
|
}
|
|
151
153
|
if (!ids.length)
|
|
152
154
|
return emptyUploads();
|
|
153
|
-
if (typeof sessionId !== "string" ||
|
|
155
|
+
if (typeof sessionId !== "string" || !SAFE_SESSION_ID.test(sessionId)) {
|
|
154
156
|
throw new Error("valid sessionId required for attachments");
|
|
155
157
|
}
|
|
156
158
|
const records = ids.map((id) => {
|
package/dist/index.serve.d.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
export * from "./serve/headless-server.js";
|
|
3
3
|
export { startProjectControlServer, type ProjectControlServerOptions, } from "./project-runtime/control-server.js";
|
|
4
4
|
export { CLOUD_MANAGEMENT_AUTHORITY_VERSION } from "./project-runtime/cloud-account-auth.js";
|
|
5
|
+
export { CLOUD_BROWSER_AUTHORITY_VERSION } from "./project-runtime/cloud-browser-auth.js";
|
|
5
6
|
export { adoptCloudAccount, inspectCloudAccountAdoption, type CloudAccountAdoptionOptions, } from "./project-runtime/account-adoption.js";
|
|
6
7
|
export type { CloudAccountAuthority, CloudAccountPrincipal, CloudManagementSession, } from "./project-runtime/control-authority.js";
|
|
7
8
|
export { parseServeArgs, resolveWebAppRoot, resolveWorkerCapabilityModules, resolveWorkerEntry, runServeCli, } from "./serve/cli.js";
|
package/dist/index.serve.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
export * from "./serve/headless-server.js";
|
|
3
3
|
export { startProjectControlServer, } from "./project-runtime/control-server.js";
|
|
4
4
|
export { CLOUD_MANAGEMENT_AUTHORITY_VERSION } from "./project-runtime/cloud-account-auth.js";
|
|
5
|
+
export { CLOUD_BROWSER_AUTHORITY_VERSION } from "./project-runtime/cloud-browser-auth.js";
|
|
5
6
|
export { adoptCloudAccount, inspectCloudAccountAdoption, } from "./project-runtime/account-adoption.js";
|
|
6
7
|
export { parseServeArgs, resolveWebAppRoot, resolveWorkerCapabilityModules, resolveWorkerEntry, runServeCli, } from "./serve/cli.js";
|
|
7
8
|
export { backupProjectInstallation, restoreProjectInstallation, } from "./project-runtime/backup.js";
|
|
@@ -7,6 +7,12 @@ export interface CloudAccountBinding {
|
|
|
7
7
|
managementEpoch: string;
|
|
8
8
|
createdAt: number;
|
|
9
9
|
}
|
|
10
|
+
/** Fresh installation policy. Each verified Account receives its own internal owner. */
|
|
11
|
+
export interface CloudMultiAccountConfiguration {
|
|
12
|
+
accountOrigin: string;
|
|
13
|
+
managementEpoch: string;
|
|
14
|
+
}
|
|
10
15
|
export declare const CLOUD_ADOPTION_PENDING = "account-adoption-pending.json";
|
|
11
16
|
export declare function isCloudAccountOrigin(value: unknown): value is string;
|
|
12
17
|
export declare function isCloudAccountBinding(value: unknown): value is CloudAccountBinding;
|
|
18
|
+
export declare function isCloudMultiAccountConfiguration(value: unknown): value is CloudMultiAccountConfiguration;
|
|
@@ -31,3 +31,12 @@ export function isCloudAccountBinding(value) {
|
|
|
31
31
|
Number.isSafeInteger(row.createdAt) &&
|
|
32
32
|
row.createdAt >= 0);
|
|
33
33
|
}
|
|
34
|
+
export function isCloudMultiAccountConfiguration(value) {
|
|
35
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
36
|
+
return false;
|
|
37
|
+
const row = value;
|
|
38
|
+
return (Object.keys(row).sort().join() === "accountOrigin,managementEpoch" &&
|
|
39
|
+
isCloudAccountOrigin(row.accountOrigin) &&
|
|
40
|
+
typeof row.managementEpoch === "string" &&
|
|
41
|
+
UUID.test(row.managementEpoch));
|
|
42
|
+
}
|
|
@@ -5,11 +5,14 @@ export interface ProjectBackupOptions {
|
|
|
5
5
|
destination: string;
|
|
6
6
|
/** Locally available, administrator-reviewed Linux image containing tar. No pull occurs. */
|
|
7
7
|
helperImage: string;
|
|
8
|
+
/** Explicit policy for a schema 3 installation; never inferred from backup data. */
|
|
9
|
+
multiAccountOrigin?: string;
|
|
8
10
|
}
|
|
9
11
|
export interface ProjectRestoreOptions {
|
|
10
12
|
source: string;
|
|
11
13
|
destination: string;
|
|
12
14
|
helperImage: string;
|
|
15
|
+
multiAccountOrigin?: string;
|
|
13
16
|
/** Fault injection for offline restore durability tests. */
|
|
14
17
|
io?: {
|
|
15
18
|
fsync?: typeof fsyncSync;
|
|
@@ -7,11 +7,13 @@ import { pipeline } from "node:stream/promises";
|
|
|
7
7
|
import { Transform } from "node:stream";
|
|
8
8
|
import { HubAuthStore } from "../hub/auth-store.js";
|
|
9
9
|
import { ProjectRegistry } from "./registry.js";
|
|
10
|
+
import { isCloudAccountOrigin } from "./account-binding.js";
|
|
10
11
|
const LABEL = "io.codeshell.project-runtime";
|
|
11
12
|
const FORMAT = "codeshell.project-installation-backup";
|
|
12
13
|
const MAX_CONTROL_FILE = 16 * 1024 * 1024;
|
|
13
14
|
const SHA = /^[a-f0-9]{64}$/;
|
|
14
15
|
const UUID = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/;
|
|
16
|
+
const MULTI_CONTROL_FILES = ["project-control/initialized", "project-control/registry.json"];
|
|
15
17
|
const digest = (value) => ({
|
|
16
18
|
sha256: createHash("sha256").update(value).digest("hex"),
|
|
17
19
|
bytes: value.length,
|
|
@@ -256,6 +258,14 @@ async function copyControl(source, destination, records, prefix = "") {
|
|
|
256
258
|
}
|
|
257
259
|
}
|
|
258
260
|
}
|
|
261
|
+
async function assertMultiControlInventory(source) {
|
|
262
|
+
for (const name of await readdir(source))
|
|
263
|
+
if (!["project-control", "project-control.lock", "project-runtime-secrets"].includes(name))
|
|
264
|
+
throw new Error("Multi-account backup refuses unknown controller state files.");
|
|
265
|
+
for (const name of await readdir(join(source, "project-control")))
|
|
266
|
+
if (!["initialized", "registry.json"].includes(name))
|
|
267
|
+
throw new Error("Multi-account backup refuses unknown project control files.");
|
|
268
|
+
}
|
|
259
269
|
/** Offline, owner-only backup. It never stops projects or modifies their volumes. */
|
|
260
270
|
export async function backupProjectInstallation(options) {
|
|
261
271
|
imageId(options.helperImage);
|
|
@@ -263,15 +273,21 @@ export async function backupProjectInstallation(options) {
|
|
|
263
273
|
await directory(source);
|
|
264
274
|
// Do not initialize a fresh registry as a side effect of a mistaken backup path.
|
|
265
275
|
await regular(join(source, "project-control/registry.json"), 512 * 1024);
|
|
266
|
-
const registry = new ProjectRegistry(source
|
|
276
|
+
const registry = new ProjectRegistry(source, Date.now, {
|
|
277
|
+
multiAccountOrigin: options.multiAccountOrigin,
|
|
278
|
+
});
|
|
267
279
|
let destination;
|
|
268
280
|
try {
|
|
269
281
|
const rows = registry.all();
|
|
270
282
|
if (rows.some((row) => row.status !== "stopped"))
|
|
271
283
|
throw new Error("Stop all projects and the controller before backup.");
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
284
|
+
if (registry.multiAccount)
|
|
285
|
+
await assertMultiControlInventory(source);
|
|
286
|
+
if (!registry.multiAccount) {
|
|
287
|
+
const auth = new HubAuthStore({ dataDir: source });
|
|
288
|
+
if (!auth.isInitialized())
|
|
289
|
+
throw new Error("Complete administrator setup before backing up.");
|
|
290
|
+
}
|
|
275
291
|
const installationId = registry.installationId;
|
|
276
292
|
const projects = rows.map(({ id, ownerId, generation }) => ({ id, ownerId, generation }));
|
|
277
293
|
await stopped(installationId, projects);
|
|
@@ -281,7 +297,8 @@ export async function backupProjectInstallation(options) {
|
|
|
281
297
|
await mkdir(join(destination, "volumes"), { mode: 0o700 });
|
|
282
298
|
const manifest = {
|
|
283
299
|
format: FORMAT,
|
|
284
|
-
version: 1,
|
|
300
|
+
version: registry.multiAccount ? 2 : 1,
|
|
301
|
+
...(registry.multiAccount ? { multiAccountOrigin: registry.multiAccount.accountOrigin } : {}),
|
|
285
302
|
createdAt: new Date().toISOString(),
|
|
286
303
|
installationId,
|
|
287
304
|
helperImage: options.helperImage,
|
|
@@ -289,7 +306,20 @@ export async function backupProjectInstallation(options) {
|
|
|
289
306
|
control: Object.create(null),
|
|
290
307
|
volumes: [],
|
|
291
308
|
};
|
|
292
|
-
|
|
309
|
+
if (registry.multiAccount) {
|
|
310
|
+
await mkdir(join(destination, "control/project-control"), { mode: 0o700 });
|
|
311
|
+
for (const path of MULTI_CONTROL_FILES) {
|
|
312
|
+
const bytes = await controlBytes(source, path);
|
|
313
|
+
await writeFile(join(destination, "control", path), bytes, {
|
|
314
|
+
mode: 0o600,
|
|
315
|
+
flag: "wx",
|
|
316
|
+
flush: true,
|
|
317
|
+
});
|
|
318
|
+
manifest.control[path] = digest(bytes);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
else
|
|
322
|
+
await copyControl(source, join(destination, "control"), manifest.control);
|
|
293
323
|
for (const project of projects) {
|
|
294
324
|
let found = 0;
|
|
295
325
|
for (const kind of ["data", "workspace"]) {
|
|
@@ -316,6 +346,8 @@ export async function backupProjectInstallation(options) {
|
|
|
316
346
|
}
|
|
317
347
|
await stopped(installationId, projects);
|
|
318
348
|
registry.all(); // Recheck registry ownership and unchanged bytes after asynchronous work.
|
|
349
|
+
if (registry.multiAccount)
|
|
350
|
+
await assertMultiControlInventory(source);
|
|
319
351
|
const manifestBytes = JSON.stringify(manifest, null, 2) + "\n";
|
|
320
352
|
if (Buffer.byteLength(manifestBytes) > 1024 * 1024)
|
|
321
353
|
throw new Error("Backup manifest exceeds its supported size.");
|
|
@@ -342,7 +374,10 @@ export async function backupProjectInstallation(options) {
|
|
|
342
374
|
}
|
|
343
375
|
function validateManifest(value) {
|
|
344
376
|
if (value?.format !== FORMAT ||
|
|
345
|
-
value.version
|
|
377
|
+
![1, 2].includes(value.version) ||
|
|
378
|
+
(value.version === 1
|
|
379
|
+
? value.multiAccountOrigin !== undefined
|
|
380
|
+
: !isCloudAccountOrigin(value.multiAccountOrigin)) ||
|
|
346
381
|
!UUID.test(value.installationId) ||
|
|
347
382
|
!Array.isArray(value.projects) ||
|
|
348
383
|
value.projects.length > 128 ||
|
|
@@ -375,6 +410,9 @@ function validateManifest(value) {
|
|
|
375
410
|
}
|
|
376
411
|
if (Object.keys(value.control).length > 4096)
|
|
377
412
|
throw new Error("Invalid control inventory.");
|
|
413
|
+
if (value.version === 2 &&
|
|
414
|
+
Object.keys(value.control).sort().join() !== MULTI_CONTROL_FILES.join())
|
|
415
|
+
throw new Error("Multi-account backup requires its exact controller inventory.");
|
|
378
416
|
for (const [path, entry] of Object.entries(value.control)) {
|
|
379
417
|
if (!path ||
|
|
380
418
|
path.includes("\\") ||
|
|
@@ -405,7 +443,7 @@ function validateManifest(value) {
|
|
|
405
443
|
for (const path of [
|
|
406
444
|
"project-control/registry.json",
|
|
407
445
|
"project-control/initialized",
|
|
408
|
-
"hub/auth.json",
|
|
446
|
+
...(value.version === 1 ? ["hub/auth.json"] : []),
|
|
409
447
|
])
|
|
410
448
|
if (!Object.hasOwn(value.control, path))
|
|
411
449
|
throw new Error("Backup lacks required control data.");
|
|
@@ -419,6 +457,8 @@ export async function restoreProjectInstallation(options) {
|
|
|
419
457
|
await directory(join(source, "volumes"));
|
|
420
458
|
const manifest = JSON.parse((await regular(join(source, "manifest.json"), 1024 * 1024)).toString());
|
|
421
459
|
validateManifest(manifest);
|
|
460
|
+
if (manifest.multiAccountOrigin !== options.multiAccountOrigin)
|
|
461
|
+
throw new Error("Restore requires the explicitly configured Account origin and matching installation mode.");
|
|
422
462
|
// Verify every archive before creating a destination or Docker resource.
|
|
423
463
|
for (const volume of manifest.volumes)
|
|
424
464
|
same(await fileDigest(join(source, volume.file)), volume.digest);
|
|
@@ -467,21 +507,26 @@ export async function restoreProjectInstallation(options) {
|
|
|
467
507
|
directories.add(parent);
|
|
468
508
|
await writeFile(join(destination, path), bytes, { mode: 0o600, flag: "wx", flush: true });
|
|
469
509
|
}
|
|
470
|
-
registry = new ProjectRegistry(destination, Date.now, {
|
|
510
|
+
registry = new ProjectRegistry(destination, Date.now, {
|
|
511
|
+
fsync: options.io?.fsync,
|
|
512
|
+
multiAccountOrigin: options.multiAccountOrigin,
|
|
513
|
+
});
|
|
471
514
|
const rows = registry.all();
|
|
472
515
|
if (registry.installationId !== manifest.installationId ||
|
|
473
516
|
rows.some((p) => p.status !== "stopped") ||
|
|
474
517
|
JSON.stringify(rows.map(({ id, ownerId, generation }) => ({ id, ownerId, generation }))) !==
|
|
475
518
|
JSON.stringify(manifest.projects))
|
|
476
519
|
throw new Error("Backup control and volume identities disagree.");
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
520
|
+
if (!registry.multiAccount) {
|
|
521
|
+
const auth = new HubAuthStore({ dataDir: destination });
|
|
522
|
+
if (!auth.isInitialized())
|
|
523
|
+
throw new Error("Backup administrator is not initialized.");
|
|
524
|
+
auth.revokeAllSessions();
|
|
525
|
+
// HubAuthStore publishes by rename. Persist the replacement and its directory
|
|
526
|
+
// before a new namespace or removal of the restore barrier can become visible.
|
|
527
|
+
sync(join(destination, "hub/auth.json"));
|
|
528
|
+
sync(join(destination, "hub"));
|
|
529
|
+
}
|
|
485
530
|
registry.renewInstallationForRestore();
|
|
486
531
|
const installationId = registry.installationId;
|
|
487
532
|
for (const volume of manifest.volumes) {
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { CloudAccountAuthority, ProjectControlAuthority } from "./control-authority.js";
|
|
2
|
+
/** Browser bootstrap is separate from the native, single-operator v2 API. */
|
|
3
|
+
export declare const CLOUD_BROWSER_AUTHORITY_VERSION = 1;
|
|
4
|
+
export declare const CLOUD_BROWSER_COOKIE = "cs_cloud_browser";
|
|
5
|
+
/** Every principal is independently scoped; a login never claims legacy projects. */
|
|
6
|
+
export declare function createCloudBrowserAuth(options: {
|
|
7
|
+
accountOrigin: string;
|
|
8
|
+
managementEpoch: string;
|
|
9
|
+
installationId: string;
|
|
10
|
+
publicOrigin: string;
|
|
11
|
+
authority: CloudAccountAuthority;
|
|
12
|
+
ownerForAccount(accountOrigin: string, accountId: string): string;
|
|
13
|
+
onRevoke(sessionId: string, ownerId: string): void;
|
|
14
|
+
now?: () => number;
|
|
15
|
+
}): ProjectControlAuthority;
|
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
2
|
+
import { isCloudAccountOrigin } from "./account-binding.js";
|
|
3
|
+
import { cloudManagementSessionView } from "./cloud-account-auth.js";
|
|
4
|
+
import { ProjectRegistryError } from "./registry.js";
|
|
5
|
+
/** Browser bootstrap is separate from the native, single-operator v2 API. */
|
|
6
|
+
export const CLOUD_BROWSER_AUTHORITY_VERSION = 1;
|
|
7
|
+
export const CLOUD_BROWSER_COOKIE = "cs_cloud_browser";
|
|
8
|
+
const TOKEN = /^[A-Za-z0-9_-]{43}$/;
|
|
9
|
+
const UUID = /^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/;
|
|
10
|
+
const hash = (token) => createHash("sha256").update(token).digest("hex");
|
|
11
|
+
function principalValid(value) {
|
|
12
|
+
return (value &&
|
|
13
|
+
Object.keys(value).sort().join() ===
|
|
14
|
+
["accountOrigin", "accountId", "sessionId", "rootSessionId", "authorityEpoch", "expiresAt"]
|
|
15
|
+
.sort()
|
|
16
|
+
.join() &&
|
|
17
|
+
isCloudAccountOrigin(value.accountOrigin) &&
|
|
18
|
+
UUID.test(value.accountId) &&
|
|
19
|
+
UUID.test(value.sessionId) &&
|
|
20
|
+
value.sessionId === value.rootSessionId &&
|
|
21
|
+
UUID.test(value.authorityEpoch) &&
|
|
22
|
+
Number.isSafeInteger(value.expiresAt));
|
|
23
|
+
}
|
|
24
|
+
/** Every principal is independently scoped; a login never claims legacy projects. */
|
|
25
|
+
export function createCloudBrowserAuth(options) {
|
|
26
|
+
if (!isCloudAccountOrigin(options.publicOrigin) ||
|
|
27
|
+
!isCloudAccountOrigin(options.accountOrigin) ||
|
|
28
|
+
options.authority.accountOrigin !== options.accountOrigin ||
|
|
29
|
+
!UUID.test(options.installationId) ||
|
|
30
|
+
!UUID.test(options.managementEpoch))
|
|
31
|
+
throw new Error("Cloud browser authority requires a fixed installation and Account origin.");
|
|
32
|
+
const now = options.now ?? Date.now;
|
|
33
|
+
const entries = new Map();
|
|
34
|
+
const reading = new Set();
|
|
35
|
+
let closed = false, exchanging = 0, attempts = { until: now() + 60_000, count: 0 };
|
|
36
|
+
function revoke(entry) {
|
|
37
|
+
if (entry.revoked)
|
|
38
|
+
return;
|
|
39
|
+
entry.revoked = true;
|
|
40
|
+
if (entries.get(entry.session.id) === entry)
|
|
41
|
+
entries.delete(entry.session.id);
|
|
42
|
+
options.onRevoke(entry.session.id, entry.session.ownerId);
|
|
43
|
+
}
|
|
44
|
+
function entryFor(session) {
|
|
45
|
+
if (closed || !("kind" in session))
|
|
46
|
+
return;
|
|
47
|
+
const entry = entries.get(session.id);
|
|
48
|
+
if (!entry || entry.revoked || entry.session.expiresAt <= now()) {
|
|
49
|
+
if (entry)
|
|
50
|
+
revoke(entry);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
// A same-root renewal may extend the deadline while a lifecycle operation
|
|
54
|
+
// still holds an older safe session snapshot. Every identity field is fixed.
|
|
55
|
+
if (Object.keys(entry.session).some((key) => key !== "expiresAt" &&
|
|
56
|
+
entry.session[key] !==
|
|
57
|
+
session[key]) ||
|
|
58
|
+
!Number.isSafeInteger(session.expiresAt) ||
|
|
59
|
+
session.expiresAt <= 0 ||
|
|
60
|
+
session.expiresAt > entry.session.expiresAt)
|
|
61
|
+
return;
|
|
62
|
+
return entry;
|
|
63
|
+
}
|
|
64
|
+
async function revalidate(session, attempt = 0) {
|
|
65
|
+
const entry = entryFor(session);
|
|
66
|
+
if (!entry)
|
|
67
|
+
throw new ProjectRegistryError(401, "Cloud 工作台登录已失效。");
|
|
68
|
+
try {
|
|
69
|
+
const current = await options.authority.validate({ ...entry.principal }, options.installationId);
|
|
70
|
+
if (entries.get(session.id) !== entry) {
|
|
71
|
+
if (attempt === 0)
|
|
72
|
+
return revalidate(session, 1);
|
|
73
|
+
throw new Error("Cloud browser authority changed while validating.");
|
|
74
|
+
}
|
|
75
|
+
if (!principalValid(current) ||
|
|
76
|
+
current.expiresAt <= now() ||
|
|
77
|
+
Object.keys(current).some((key) => current[key] !==
|
|
78
|
+
entry.principal[key]) ||
|
|
79
|
+
!entryFor(session))
|
|
80
|
+
throw new Error("Cloud browser authority changed.");
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
// A delayed validation of the preceding token/root deadline must never
|
|
84
|
+
// revoke a newer, independently redeemed cookie for the same scope.
|
|
85
|
+
if (entries.get(session.id) !== entry) {
|
|
86
|
+
if (attempt === 0)
|
|
87
|
+
return revalidate(session, 1);
|
|
88
|
+
}
|
|
89
|
+
else
|
|
90
|
+
revoke(entry);
|
|
91
|
+
throw new ProjectRegistryError(401, "Cloud 工作台授权已失效,请重新登录。");
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
function uniqueHeader(req, name) {
|
|
95
|
+
return (req.rawHeaders.filter((key, index) => index % 2 === 0 && key.toLowerCase() === name).length <=
|
|
96
|
+
1);
|
|
97
|
+
}
|
|
98
|
+
function isOriginAllowed(req) {
|
|
99
|
+
if (!uniqueHeader(req, "host") ||
|
|
100
|
+
!uniqueHeader(req, "origin") ||
|
|
101
|
+
!uniqueHeader(req, "cookie") ||
|
|
102
|
+
!uniqueHeader(req, "sec-fetch-site") ||
|
|
103
|
+
!uniqueHeader(req, "content-type") ||
|
|
104
|
+
req.headers.host !== new URL(options.publicOrigin).host ||
|
|
105
|
+
req.headers.authorization !== undefined ||
|
|
106
|
+
(req.headers.origin !== undefined && req.headers.origin !== options.publicOrigin) ||
|
|
107
|
+
![undefined, "same-origin", "none"].includes(req.headers["sec-fetch-site"]))
|
|
108
|
+
return false;
|
|
109
|
+
if ((req.headers.cookie
|
|
110
|
+
?.split(";")
|
|
111
|
+
.filter((part) => part.trim().startsWith(`${CLOUD_BROWSER_COOKIE}=`)).length ?? 0) > 1)
|
|
112
|
+
return false;
|
|
113
|
+
// Unsafe browser requests require both independently supplied browser checks.
|
|
114
|
+
// JSON alone is not the CSRF boundary and no CORS exception is installed.
|
|
115
|
+
return (["GET", "HEAD"].includes(req.method ?? "") ||
|
|
116
|
+
(req.headers.origin === options.publicOrigin &&
|
|
117
|
+
req.headers["sec-fetch-site"] === "same-origin"));
|
|
118
|
+
}
|
|
119
|
+
function cookieToken(req) {
|
|
120
|
+
const cookies = req.headers.cookie
|
|
121
|
+
?.split(";")
|
|
122
|
+
.map((part) => part.trim())
|
|
123
|
+
.filter((part) => part.startsWith(`${CLOUD_BROWSER_COOKIE}=`));
|
|
124
|
+
if (cookies?.length === 1 && TOKEN.test(cookies[0].slice(CLOUD_BROWSER_COOKIE.length + 1)))
|
|
125
|
+
return cookies[0].slice(CLOUD_BROWSER_COOKIE.length + 1);
|
|
126
|
+
}
|
|
127
|
+
function entryFromRequest(req) {
|
|
128
|
+
const token = cookieToken(req);
|
|
129
|
+
if (token)
|
|
130
|
+
return [...entries.values()].find((row) => row.hash === hash(token));
|
|
131
|
+
}
|
|
132
|
+
async function authenticate(req) {
|
|
133
|
+
if (!isOriginAllowed(req))
|
|
134
|
+
return null;
|
|
135
|
+
const entry = entryFromRequest(req);
|
|
136
|
+
if (!entry || !entryFor(entry.session))
|
|
137
|
+
return null;
|
|
138
|
+
await revalidate(entry.session);
|
|
139
|
+
return { ...entry.session };
|
|
140
|
+
}
|
|
141
|
+
function json(res, status, value) {
|
|
142
|
+
if (res.destroyed || res.writableEnded)
|
|
143
|
+
return;
|
|
144
|
+
res.writeHead(status, {
|
|
145
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
146
|
+
"Cache-Control": "no-store",
|
|
147
|
+
"Referrer-Policy": "no-referrer",
|
|
148
|
+
"X-Content-Type-Options": "nosniff",
|
|
149
|
+
"Content-Security-Policy": "frame-ancestors 'none'",
|
|
150
|
+
});
|
|
151
|
+
res.end(JSON.stringify(value));
|
|
152
|
+
}
|
|
153
|
+
function setCookie(res, token, expiresAt) {
|
|
154
|
+
res.setHeader("Set-Cookie", `${CLOUD_BROWSER_COOKIE}=${token}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${Math.max(0, Math.floor((expiresAt - now()) / 1000))}${options.publicOrigin.startsWith("https:") ? "; Secure" : ""}`);
|
|
155
|
+
}
|
|
156
|
+
async function readBody(req) {
|
|
157
|
+
if (!/^application\/json(?:\s*;|$)/i.test(req.headers["content-type"] ?? ""))
|
|
158
|
+
throw new ProjectRegistryError(415, "请使用 JSON 提交。");
|
|
159
|
+
const chunks = [];
|
|
160
|
+
let size = 0;
|
|
161
|
+
const timer = setTimeout(() => req.destroy(), 5000);
|
|
162
|
+
timer.unref();
|
|
163
|
+
reading.add(req);
|
|
164
|
+
try {
|
|
165
|
+
for await (const chunk of req) {
|
|
166
|
+
size += chunk.length;
|
|
167
|
+
if (size > 4096)
|
|
168
|
+
throw new ProjectRegistryError(413, "请求过大。");
|
|
169
|
+
chunks.push(Buffer.from(chunk));
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
finally {
|
|
173
|
+
clearTimeout(timer);
|
|
174
|
+
reading.delete(req);
|
|
175
|
+
}
|
|
176
|
+
try {
|
|
177
|
+
const body = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
178
|
+
if (body && typeof body === "object" && !Array.isArray(body))
|
|
179
|
+
return body;
|
|
180
|
+
}
|
|
181
|
+
catch {
|
|
182
|
+
/* Only the fixed public error below is returned. */
|
|
183
|
+
}
|
|
184
|
+
throw new ProjectRegistryError(400, "请求格式无效。");
|
|
185
|
+
}
|
|
186
|
+
async function handle(req, res) {
|
|
187
|
+
const path = (req.url ?? "/").split("?", 1)[0];
|
|
188
|
+
if (!path.startsWith("/api/v1/cloud-browser/") &&
|
|
189
|
+
!path.startsWith("/api/v1/cloud-management/") &&
|
|
190
|
+
!path.startsWith("/api/v1/auth/"))
|
|
191
|
+
return false;
|
|
192
|
+
if (!isOriginAllowed(req))
|
|
193
|
+
throw new ProjectRegistryError(403, "请求来源无效。");
|
|
194
|
+
if (closed)
|
|
195
|
+
throw new ProjectRegistryError(503, "Cloud 工作台正在关闭。");
|
|
196
|
+
if (req.url !== path)
|
|
197
|
+
throw new ProjectRegistryError(400, "请求参数无效。");
|
|
198
|
+
if (["GET", "HEAD"].includes(req.method ?? "") &&
|
|
199
|
+
(Number(req.headers["content-length"] ?? 0) !== 0 ||
|
|
200
|
+
req.headers["transfer-encoding"] !== undefined))
|
|
201
|
+
throw new ProjectRegistryError(400, "读取请求不接受额外内容。");
|
|
202
|
+
// The controller owns the environment DTO; retain this authority's exact
|
|
203
|
+
// admission checks before delegating its browser-only alias.
|
|
204
|
+
if (path === "/api/v1/cloud-browser/environment")
|
|
205
|
+
return false;
|
|
206
|
+
if (path === "/api/v1/cloud-browser/capabilities" && req.method === "GET") {
|
|
207
|
+
json(res, 200, {
|
|
208
|
+
version: CLOUD_BROWSER_AUTHORITY_VERSION,
|
|
209
|
+
mode: "cloud-browser",
|
|
210
|
+
cloudOrigin: options.publicOrigin,
|
|
211
|
+
installationId: options.installationId,
|
|
212
|
+
accountOrigin: options.accountOrigin,
|
|
213
|
+
});
|
|
214
|
+
return true;
|
|
215
|
+
}
|
|
216
|
+
if (path === "/api/v1/cloud-browser/session" && req.method === "POST") {
|
|
217
|
+
if (now() >= attempts.until)
|
|
218
|
+
attempts = { until: now() + 60_000, count: 0 };
|
|
219
|
+
if (++attempts.count > 60 || exchanging >= 4)
|
|
220
|
+
throw new ProjectRegistryError(429, "Cloud 登录请求过多。");
|
|
221
|
+
// Capture before either request-body or private redemption awaits. A
|
|
222
|
+
// logout/account switch must fence the old request's eventual Set-Cookie.
|
|
223
|
+
const previous = entryFromRequest(req);
|
|
224
|
+
const wasRevoked = previous?.revoked;
|
|
225
|
+
exchanging++;
|
|
226
|
+
try {
|
|
227
|
+
const body = await readBody(req);
|
|
228
|
+
if (Object.keys(body).join() !== "ticket" ||
|
|
229
|
+
typeof body.ticket !== "string" ||
|
|
230
|
+
!TOKEN.test(body.ticket))
|
|
231
|
+
throw new ProjectRegistryError(400, "Cloud 登录票据格式无效。");
|
|
232
|
+
let principal;
|
|
233
|
+
try {
|
|
234
|
+
principal = await options.authority.redeem(body.ticket, options.installationId);
|
|
235
|
+
}
|
|
236
|
+
catch {
|
|
237
|
+
throw new ProjectRegistryError(401, "云账号验证失败。");
|
|
238
|
+
}
|
|
239
|
+
if (closed ||
|
|
240
|
+
res.destroyed ||
|
|
241
|
+
res.writableEnded ||
|
|
242
|
+
!principalValid(principal) ||
|
|
243
|
+
principal.accountOrigin !== options.accountOrigin ||
|
|
244
|
+
principal.expiresAt <= now())
|
|
245
|
+
throw new ProjectRegistryError(401, "云账号验证失败。");
|
|
246
|
+
if (previous &&
|
|
247
|
+
(entries.get(previous.session.id) !== previous || previous.revoked !== wasRevoked))
|
|
248
|
+
throw new ProjectRegistryError(401, "Cloud 登录状态已改变,请重新进入工作台。");
|
|
249
|
+
for (const [id, entry] of entries)
|
|
250
|
+
if (entry.session.expiresAt <= now()) {
|
|
251
|
+
revoke(entry);
|
|
252
|
+
entries.delete(id);
|
|
253
|
+
}
|
|
254
|
+
const renewal = previous &&
|
|
255
|
+
entryFor(previous.session) &&
|
|
256
|
+
previous.principal.accountOrigin === principal.accountOrigin &&
|
|
257
|
+
previous.principal.accountId === principal.accountId &&
|
|
258
|
+
previous.principal.rootSessionId === principal.rootSessionId &&
|
|
259
|
+
previous.principal.authorityEpoch === principal.authorityEpoch &&
|
|
260
|
+
principal.expiresAt >= previous.principal.expiresAt;
|
|
261
|
+
const active = [...entries.values()].filter((entry) => !entry.revoked);
|
|
262
|
+
if (entries.size - (renewal ? 1 : 0) >= 256 ||
|
|
263
|
+
active.filter((entry) => entry.session.accountId === principal.accountId && entry !== previous).length >= 8)
|
|
264
|
+
throw new ProjectRegistryError(429, "Cloud 登录会话数量已达上限。");
|
|
265
|
+
const ownerId = options.ownerForAccount(principal.accountOrigin, principal.accountId);
|
|
266
|
+
if (!UUID.test(ownerId))
|
|
267
|
+
throw new ProjectRegistryError(503, "Cloud 工作区暂不可用。");
|
|
268
|
+
const token = randomBytes(32).toString("base64url");
|
|
269
|
+
const session = {
|
|
270
|
+
id: renewal ? previous.session.id : randomUUID(),
|
|
271
|
+
kind: "cloud-management",
|
|
272
|
+
audience: options.publicOrigin,
|
|
273
|
+
installationId: options.installationId,
|
|
274
|
+
accountOrigin: principal.accountOrigin,
|
|
275
|
+
accountId: principal.accountId,
|
|
276
|
+
managementEpoch: options.managementEpoch,
|
|
277
|
+
expiresAt: Math.min(now() + 600_000, principal.expiresAt),
|
|
278
|
+
ownerId,
|
|
279
|
+
};
|
|
280
|
+
entries.set(session.id, {
|
|
281
|
+
hash: hash(token),
|
|
282
|
+
session,
|
|
283
|
+
principal: { ...principal },
|
|
284
|
+
revoked: false,
|
|
285
|
+
});
|
|
286
|
+
if (renewal)
|
|
287
|
+
previous.revoked = true;
|
|
288
|
+
else if (previous)
|
|
289
|
+
revoke(previous);
|
|
290
|
+
setCookie(res, token, session.expiresAt);
|
|
291
|
+
json(res, 201, {
|
|
292
|
+
authenticated: true,
|
|
293
|
+
mode: "cloud-account",
|
|
294
|
+
session: cloudManagementSessionView(session),
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
finally {
|
|
298
|
+
exchanging--;
|
|
299
|
+
}
|
|
300
|
+
return true;
|
|
301
|
+
}
|
|
302
|
+
if (["/api/v1/auth/status", "/api/v1/cloud-browser/status"].includes(path) &&
|
|
303
|
+
req.method === "GET") {
|
|
304
|
+
const session = await authenticate(req);
|
|
305
|
+
json(res, 200, {
|
|
306
|
+
initialized: true,
|
|
307
|
+
mode: "cloud-account",
|
|
308
|
+
authenticated: !!session,
|
|
309
|
+
...(session && "kind" in session ? { session: cloudManagementSessionView(session) } : {}),
|
|
310
|
+
});
|
|
311
|
+
return true;
|
|
312
|
+
}
|
|
313
|
+
if (["/api/v1/cloud-browser/logout", "/api/v1/auth/logout"].includes(path) &&
|
|
314
|
+
req.method === "POST") {
|
|
315
|
+
const entry = entryFromRequest(req);
|
|
316
|
+
const body = await readBody(req);
|
|
317
|
+
if (Object.keys(body).length)
|
|
318
|
+
throw new ProjectRegistryError(400, "退出不接受额外参数。");
|
|
319
|
+
if (closed)
|
|
320
|
+
throw new ProjectRegistryError(503, "Cloud 工作台正在关闭。");
|
|
321
|
+
if (!entry || entry.revoked || entries.get(entry.session.id) !== entry)
|
|
322
|
+
throw new ProjectRegistryError(401, "Cloud 工作台登录已失效。");
|
|
323
|
+
revoke(entry);
|
|
324
|
+
setCookie(res, "", now());
|
|
325
|
+
json(res, 200, { revoked: true });
|
|
326
|
+
return true;
|
|
327
|
+
}
|
|
328
|
+
throw new ProjectRegistryError(403, "此 Cloud 使用独立账号的浏览器工作台登录。");
|
|
329
|
+
}
|
|
330
|
+
return {
|
|
331
|
+
cookieName: CLOUD_BROWSER_COOKIE,
|
|
332
|
+
store: {
|
|
333
|
+
listSessions: () => [...entries.values()]
|
|
334
|
+
.filter((row) => entryFor(row.session))
|
|
335
|
+
.map((row) => ({ ...row.session })),
|
|
336
|
+
},
|
|
337
|
+
handle,
|
|
338
|
+
authenticate,
|
|
339
|
+
isOriginAllowed,
|
|
340
|
+
isActive: (session) => !!entryFor(session),
|
|
341
|
+
revalidate,
|
|
342
|
+
close() {
|
|
343
|
+
closed = true;
|
|
344
|
+
for (const request of reading)
|
|
345
|
+
request.destroy();
|
|
346
|
+
reading.clear();
|
|
347
|
+
for (const entry of entries.values())
|
|
348
|
+
revoke(entry);
|
|
349
|
+
entries.clear();
|
|
350
|
+
},
|
|
351
|
+
};
|
|
352
|
+
}
|
|
@@ -11,10 +11,24 @@ export interface ProjectControlServerOptions {
|
|
|
11
11
|
runtimeImage?: string;
|
|
12
12
|
/** Trusted operator configuration, never accepted from project requests. */
|
|
13
13
|
runtimeSeccompProfile?: string;
|
|
14
|
+
/** Administrator-owned runtime resources; project requests cannot override these. */
|
|
15
|
+
runtimeLimits?: {
|
|
16
|
+
memoryMb?: number;
|
|
17
|
+
cpus?: number;
|
|
18
|
+
pids?: number;
|
|
19
|
+
};
|
|
20
|
+
/** Installation and per-account project quotas from trusted server configuration. */
|
|
21
|
+
projectLimits?: {
|
|
22
|
+
maxRunning?: number;
|
|
23
|
+
maxRunningPerOwner?: number;
|
|
24
|
+
maxProjectsPerOwner?: number;
|
|
25
|
+
};
|
|
14
26
|
/** Dependency injection for lifecycle/transport integration tests. */
|
|
15
27
|
provider?: ProjectRuntimeProvider;
|
|
16
28
|
/** Trusted fixed Account-origin verifier. Requires explicit offline ownership adoption. */
|
|
17
29
|
accountAuthority?: CloudAccountAuthority;
|
|
30
|
+
/** Explicit fresh-install multi-account browser workspaces; never adopts old owners. */
|
|
31
|
+
accountMode?: "multi-account";
|
|
18
32
|
}
|
|
19
33
|
/** A small account/project gateway. It never creates a worker, executes tools or mounts cwd. */
|
|
20
34
|
export declare function startProjectControlServer(options: ProjectControlServerOptions): Promise<{
|
|
@@ -6,10 +6,11 @@ import { join } from "node:path";
|
|
|
6
6
|
import { extname } from "node:path";
|
|
7
7
|
import { createHubAuth } from "../hub/auth-http.js";
|
|
8
8
|
import { createCloudAccountAuth } from "./cloud-account-auth.js";
|
|
9
|
+
import { createCloudBrowserAuth } from "./cloud-browser-auth.js";
|
|
9
10
|
import { legacyProjectAuthority } from "./control-authority.js";
|
|
10
11
|
import { resolveSafe } from "../mobile-remote/mobile-static.js";
|
|
11
12
|
import { contentTypeFor } from "../static-files.js";
|
|
12
|
-
import { createDockerProjectProvider } from "./docker-provider.js";
|
|
13
|
+
import { createDockerProjectProvider, resolveProjectRuntimeLimits } from "./docker-provider.js";
|
|
13
14
|
import { ProjectManager } from "./manager.js";
|
|
14
15
|
import { createProjectRuntimeProxy } from "./proxy.js";
|
|
15
16
|
import { ProjectRegistry, ProjectRegistryError } from "./registry.js";
|
|
@@ -70,6 +71,11 @@ async function readBody(request) {
|
|
|
70
71
|
}
|
|
71
72
|
/** A small account/project gateway. It never creates a worker, executes tools or mounts cwd. */
|
|
72
73
|
export async function startProjectControlServer(options) {
|
|
74
|
+
const runtimeLimits = resolveProjectRuntimeLimits(options.runtimeLimits);
|
|
75
|
+
if ((options.accountMode !== undefined && options.accountMode !== "multi-account") ||
|
|
76
|
+
(options.accountMode === "multi-account" &&
|
|
77
|
+
(!options.accountAuthority || !options.publicOrigin)))
|
|
78
|
+
throw new Error("Multi-account Cloud requires an explicit fixed Account authority and public origin.");
|
|
73
79
|
const remoteLink = remoteLinkHostConfiguration(options.remoteLink, options.publicOrigin);
|
|
74
80
|
// Any entry is a barrier, including dangling symlinks. Only ENOENT means absent;
|
|
75
81
|
// permission/I/O failures must abort before registry or Hub initialization.
|
|
@@ -81,23 +87,30 @@ export async function startProjectControlServer(options) {
|
|
|
81
87
|
if (error.code !== "ENOENT")
|
|
82
88
|
throw error;
|
|
83
89
|
}
|
|
84
|
-
const registry = new ProjectRegistry(options.dataDir
|
|
90
|
+
const registry = new ProjectRegistry(options.dataDir, Date.now, {
|
|
91
|
+
...(options.accountMode === "multi-account"
|
|
92
|
+
? { multiAccountOrigin: options.accountAuthority.accountOrigin }
|
|
93
|
+
: {}),
|
|
94
|
+
});
|
|
85
95
|
let proxy;
|
|
86
96
|
let manager;
|
|
87
97
|
let provider;
|
|
88
98
|
let server;
|
|
89
99
|
let shuttingDown = false;
|
|
90
100
|
try {
|
|
91
|
-
const onRevoke = (sessionId) => {
|
|
101
|
+
const onRevoke = (sessionId, ownerId) => {
|
|
92
102
|
if (shuttingDown)
|
|
93
103
|
return;
|
|
94
104
|
void proxy?.revokeOwner(sessionId).catch(async () => {
|
|
95
105
|
if (shuttingDown)
|
|
96
106
|
return;
|
|
97
|
-
//
|
|
98
|
-
//
|
|
107
|
+
// Transports are already closed. If inner revocation fails, terminate
|
|
108
|
+
// only this Account owner's runtimes; legacy keeps its single-owner fallback.
|
|
99
109
|
try {
|
|
100
|
-
|
|
110
|
+
if (options.accountMode === "multi-account" && ownerId)
|
|
111
|
+
await manager?.stopOwner(ownerId);
|
|
112
|
+
else
|
|
113
|
+
await manager?.stopAll();
|
|
101
114
|
}
|
|
102
115
|
catch {
|
|
103
116
|
console.error("[project-control] could not confirm project termination after session revocation.");
|
|
@@ -105,21 +118,32 @@ export async function startProjectControlServer(options) {
|
|
|
105
118
|
});
|
|
106
119
|
};
|
|
107
120
|
const binding = registry.accountBinding;
|
|
108
|
-
|
|
121
|
+
const multiAccount = registry.multiAccount;
|
|
122
|
+
if (!!(binding || multiAccount) !== !!options.accountAuthority ||
|
|
123
|
+
((binding || multiAccount) && !options.publicOrigin))
|
|
109
124
|
throw new Error("Cloud account authority requires explicit matching ownership and public origin.");
|
|
110
|
-
const auth =
|
|
111
|
-
?
|
|
112
|
-
|
|
125
|
+
const auth = multiAccount
|
|
126
|
+
? createCloudBrowserAuth({
|
|
127
|
+
...multiAccount,
|
|
113
128
|
installationId: registry.installationId,
|
|
114
129
|
publicOrigin: options.publicOrigin,
|
|
115
130
|
authority: options.accountAuthority,
|
|
131
|
+
ownerForAccount: (origin, id) => registry.ensureAccountOwner(origin, id),
|
|
116
132
|
onRevoke,
|
|
117
133
|
})
|
|
118
|
-
:
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
134
|
+
: binding
|
|
135
|
+
? createCloudAccountAuth({
|
|
136
|
+
binding,
|
|
137
|
+
installationId: registry.installationId,
|
|
138
|
+
publicOrigin: options.publicOrigin,
|
|
139
|
+
authority: options.accountAuthority,
|
|
140
|
+
onRevoke,
|
|
141
|
+
})
|
|
142
|
+
: legacyProjectAuthority(await createHubAuth({
|
|
143
|
+
dataDir: options.dataDir,
|
|
144
|
+
publicOrigin: options.publicOrigin,
|
|
145
|
+
onRevoke,
|
|
146
|
+
}));
|
|
123
147
|
let listeningOrigin = "";
|
|
124
148
|
const publicOrigin = () => options.publicOrigin ?? listeningOrigin;
|
|
125
149
|
provider =
|
|
@@ -128,15 +152,19 @@ export async function startProjectControlServer(options) {
|
|
|
128
152
|
installationId: registry.installationId,
|
|
129
153
|
dataDir: options.dataDir,
|
|
130
154
|
image: options.runtimeImage,
|
|
155
|
+
limits: runtimeLimits,
|
|
131
156
|
seccompProfile: options.runtimeSeccompProfile,
|
|
132
157
|
remoteLink,
|
|
133
158
|
});
|
|
134
159
|
manager = new ProjectManager({
|
|
160
|
+
maxRunning: options.projectLimits?.maxRunning,
|
|
161
|
+
maxRunningPerOwner: options.projectLimits?.maxRunningPerOwner,
|
|
162
|
+
maxProjectsPerOwner: options.projectLimits?.maxProjectsPerOwner,
|
|
135
163
|
registry,
|
|
136
164
|
provider,
|
|
137
165
|
publicOrigin,
|
|
138
166
|
isSessionActive: (session) => auth.isActive(session),
|
|
139
|
-
...(binding
|
|
167
|
+
...(binding || multiAccount
|
|
140
168
|
? {
|
|
141
169
|
revalidateSession: (session) => auth.revalidate(session),
|
|
142
170
|
}
|
|
@@ -177,7 +205,10 @@ export async function startProjectControlServer(options) {
|
|
|
177
205
|
return;
|
|
178
206
|
if (await transport.handle(request, response))
|
|
179
207
|
return;
|
|
180
|
-
if (pathname === "/api/v1/environment"
|
|
208
|
+
if (pathname === "/api/v1/environment" ||
|
|
209
|
+
(multiAccount && pathname === "/api/v1/cloud-browser/environment")) {
|
|
210
|
+
if (pathname === "/api/v1/cloud-browser/environment" && url.search)
|
|
211
|
+
throw new ProjectRegistryError(400, "环境请求参数无效。");
|
|
181
212
|
if (!auth.isOriginAllowed(request))
|
|
182
213
|
throw new ProjectRegistryError(403, "请求来源无效。");
|
|
183
214
|
if (!(await auth.authenticate(request)))
|
|
@@ -263,6 +294,7 @@ export async function startProjectControlServer(options) {
|
|
|
263
294
|
"Content-Type": contentTypeFor(extname(file)),
|
|
264
295
|
"Content-Length": bytes.length,
|
|
265
296
|
"Cache-Control": "no-cache",
|
|
297
|
+
...(multiAccount ? { "Content-Security-Policy": "frame-ancestors 'none'" } : {}),
|
|
266
298
|
});
|
|
267
299
|
response.end(request.method === "HEAD" ? undefined : bytes);
|
|
268
300
|
};
|
|
@@ -33,5 +33,11 @@ export declare class ProjectRuntimeError extends Error {
|
|
|
33
33
|
}
|
|
34
34
|
export declare function validateProjectRuntimeRecord(project: ProjectRuntimeRecord): void;
|
|
35
35
|
export declare function validateProjectPublicOrigin(value: string): string;
|
|
36
|
+
/** Shared trusted configuration parsing, including hosts with an injected provider. */
|
|
37
|
+
export declare function resolveProjectRuntimeLimits(limits: DockerProjectProviderOptions["limits"]): {
|
|
38
|
+
memoryMb: number;
|
|
39
|
+
cpus: number;
|
|
40
|
+
pids: number;
|
|
41
|
+
};
|
|
36
42
|
/** An administrator-owned Docker control client. Project tasks never receive this client. */
|
|
37
43
|
export declare function createDockerProjectProvider(options: DockerProjectProviderOptions): ProjectRuntimeProvider;
|
|
@@ -103,6 +103,15 @@ async function within(promise, signal) {
|
|
|
103
103
|
signal.removeEventListener("abort", onAbort);
|
|
104
104
|
}
|
|
105
105
|
}
|
|
106
|
+
/** Shared trusted configuration parsing, including hosts with an injected provider. */
|
|
107
|
+
export function resolveProjectRuntimeLimits(limits) {
|
|
108
|
+
const memoryMb = bounded(limits?.memoryMb ?? 2048, 128, 262144, "memory");
|
|
109
|
+
const cpus = bounded(limits?.cpus ?? 2, 0.1, 128, "CPU");
|
|
110
|
+
const pids = bounded(limits?.pids ?? 256, 16, 65536, "process");
|
|
111
|
+
if (!Number.isInteger(memoryMb) || !Number.isInteger(pids))
|
|
112
|
+
throw new ProjectRuntimeError("Invalid resource limits.");
|
|
113
|
+
return { memoryMb, cpus, pids };
|
|
114
|
+
}
|
|
106
115
|
/** An administrator-owned Docker control client. Project tasks never receive this client. */
|
|
107
116
|
export function createDockerProjectProvider(options) {
|
|
108
117
|
if (!UUID.test(options.installationId))
|
|
@@ -116,11 +125,7 @@ export function createDockerProjectProvider(options) {
|
|
|
116
125
|
const dataDir = resolve(options.dataDir);
|
|
117
126
|
if (/[\0,\r\n]/.test(dataDir))
|
|
118
127
|
throw new ProjectRuntimeError("Unsupported project metadata path.");
|
|
119
|
-
const memory =
|
|
120
|
-
const cpus = bounded(options.limits?.cpus ?? 2, 0.1, 128, "CPU");
|
|
121
|
-
const pids = bounded(options.limits?.pids ?? 256, 16, 65536, "process");
|
|
122
|
-
if (!Number.isInteger(memory) || !Number.isInteger(pids))
|
|
123
|
-
throw new ProjectRuntimeError("Invalid resource limits.");
|
|
128
|
+
const { memoryMb: memory, cpus, pids } = resolveProjectRuntimeLimits(options.limits);
|
|
124
129
|
const commandTimeout = bounded(options.commandTimeoutMs ?? 15000, 10, 120000, "command timeout");
|
|
125
130
|
const startupTimeout = bounded(options.startupTimeoutMs ?? 90000, 10, 300000, "startup timeout");
|
|
126
131
|
const command = options.command ?? dockerCommand;
|
|
@@ -9,6 +9,8 @@ export interface ProjectManagerOptions {
|
|
|
9
9
|
revokeProject(id: string): Promise<void>;
|
|
10
10
|
revalidateSession?(session: ProjectControlSession): Promise<void>;
|
|
11
11
|
maxRunning?: number;
|
|
12
|
+
maxRunningPerOwner?: number;
|
|
13
|
+
maxProjectsPerOwner?: number;
|
|
12
14
|
/** Clock seam for Docker observations; authorization is never cached. */
|
|
13
15
|
now?: () => number;
|
|
14
16
|
}
|
|
@@ -40,5 +42,7 @@ export declare class ProjectManager {
|
|
|
40
42
|
stopAfterRevocationFailure(id: string): Promise<void>;
|
|
41
43
|
/** Emergency fallback when an outer owner's affected projects are unavailable. */
|
|
42
44
|
stopAll(): Promise<void>;
|
|
45
|
+
/** Revocation fallback for one Account; never interrupts another owner's runtimes. */
|
|
46
|
+
stopOwner(ownerId: string): Promise<void>;
|
|
43
47
|
close(): Promise<void>;
|
|
44
48
|
}
|
|
@@ -15,6 +15,12 @@ export class ProjectManager {
|
|
|
15
15
|
(options.maxRunning ?? 4) < 1 ||
|
|
16
16
|
(options.maxRunning ?? 4) > 32)
|
|
17
17
|
throw new Error("Project running limit must be between 1 and 32.");
|
|
18
|
+
for (const [name, value, maximum] of [
|
|
19
|
+
["running per owner", options.maxRunningPerOwner ?? 2, 32],
|
|
20
|
+
["projects per owner", options.maxProjectsPerOwner ?? 16, 128],
|
|
21
|
+
])
|
|
22
|
+
if (!Number.isSafeInteger(value) || value < 1 || value > maximum)
|
|
23
|
+
throw new Error(`Project ${name} limit must be between 1 and ${maximum}.`);
|
|
18
24
|
}
|
|
19
25
|
authorize(session) {
|
|
20
26
|
if (this.closed)
|
|
@@ -62,6 +68,11 @@ export class ProjectManager {
|
|
|
62
68
|
}
|
|
63
69
|
create(session, name) {
|
|
64
70
|
this.authorize(session);
|
|
71
|
+
if (this.options.registry.multiAccount &&
|
|
72
|
+
"kind" in session &&
|
|
73
|
+
this.options.registry.listAccount(session.ownerId).length >=
|
|
74
|
+
(this.options.maxProjectsPerOwner ?? 16))
|
|
75
|
+
throw new ProjectRegistryError(429, `每个账号最多创建 ${this.options.maxProjectsPerOwner ?? 16} 个项目。`);
|
|
65
76
|
return projectView("kind" in session
|
|
66
77
|
? this.options.registry.createAccount(session.ownerId, name)
|
|
67
78
|
: this.options.registry.create(session.username, name));
|
|
@@ -104,6 +115,16 @@ export class ProjectManager {
|
|
|
104
115
|
item.generation > 0).length;
|
|
105
116
|
if (count >= (this.options.maxRunning ?? 4))
|
|
106
117
|
throw new ProjectRegistryError(429, `最多同时运行 ${this.options.maxRunning ?? 4} 个项目,请先停止其他项目。`);
|
|
118
|
+
if (this.options.registry.multiAccount) {
|
|
119
|
+
const ownerCount = this.options.registry
|
|
120
|
+
.all()
|
|
121
|
+
.filter((item) => item.ownerId === project.ownerId &&
|
|
122
|
+
item.id !== id &&
|
|
123
|
+
["running", "starting", "stopping", "error"].includes(item.status) &&
|
|
124
|
+
item.generation > 0).length;
|
|
125
|
+
if (ownerCount >= (this.options.maxRunningPerOwner ?? 2))
|
|
126
|
+
throw new ProjectRegistryError(429, `每个账号最多同时运行 ${this.options.maxRunningPerOwner ?? 2} 个项目。`);
|
|
127
|
+
}
|
|
107
128
|
this.statusChecks.delete(id);
|
|
108
129
|
const record = this.options.registry.update(id, {
|
|
109
130
|
status: "starting",
|
|
@@ -273,6 +294,18 @@ export class ProjectManager {
|
|
|
273
294
|
if (results.some((result) => result.status === "rejected" || result.value.status !== "stopped"))
|
|
274
295
|
throw new ProjectRegistryError(503, "部分项目尚未确认停止,请检查 Docker。");
|
|
275
296
|
}
|
|
297
|
+
/** Revocation fallback for one Account; never interrupts another owner's runtimes. */
|
|
298
|
+
async stopOwner(ownerId) {
|
|
299
|
+
this.options.registry.listAccount(ownerId); // Require an actual Account owner.
|
|
300
|
+
const projects = this.options.registry.all().filter((project) => project.ownerId === ownerId);
|
|
301
|
+
for (const project of projects)
|
|
302
|
+
this.stopRevisions.set(project.id, (this.stopRevisions.get(project.id) ?? 0) + 1);
|
|
303
|
+
const results = await Promise.allSettled(projects
|
|
304
|
+
.filter((project) => project.generation > 0 && project.status !== "stopped")
|
|
305
|
+
.map((project) => this.beginStop(project.id)));
|
|
306
|
+
if (results.some((result) => result.status === "rejected" || result.value.status !== "stopped"))
|
|
307
|
+
throw new ProjectRegistryError(503, "部分账号项目尚未确认停止,请检查 Docker。");
|
|
308
|
+
}
|
|
276
309
|
close() {
|
|
277
310
|
if (this.closing)
|
|
278
311
|
return this.closing;
|
|
@@ -2,6 +2,7 @@ import { request as httpRequest } from "node:http";
|
|
|
2
2
|
import { Transform } from "node:stream";
|
|
3
3
|
import { WebSocket, WebSocketServer } from "ws";
|
|
4
4
|
import { HUB_SESSION_COOKIE } from "../hub/auth-http.js";
|
|
5
|
+
import { ProjectRegistryError } from "./registry.js";
|
|
5
6
|
import { createLinkBrowserHandoff } from "../links/browser-handoff.js";
|
|
6
7
|
const UUID = "[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}";
|
|
7
8
|
const PROJECT_PATH = new RegExp(`^/p/(${UUID})(/.*)$`);
|
|
@@ -753,13 +754,19 @@ function httpError(res, error) {
|
|
|
753
754
|
res.destroy();
|
|
754
755
|
return;
|
|
755
756
|
}
|
|
756
|
-
const status = error instanceof ProxyError ? error.status : 503;
|
|
757
|
+
const status = error instanceof ProxyError || error instanceof ProjectRegistryError ? error.status : 503;
|
|
757
758
|
res.writeHead(status, { "Content-Type": "application/json", "Cache-Control": "no-store" });
|
|
758
759
|
res.end(JSON.stringify({
|
|
759
|
-
error: error instanceof ProxyError
|
|
760
|
+
error: error instanceof ProxyError
|
|
761
|
+
? error.message
|
|
762
|
+
: status === 401
|
|
763
|
+
? "Authentication required"
|
|
764
|
+
: status === 404
|
|
765
|
+
? "Project endpoint not found"
|
|
766
|
+
: "Project runtime unavailable",
|
|
760
767
|
}));
|
|
761
768
|
}
|
|
762
769
|
function upgradeError(socket, error) {
|
|
763
|
-
const status = error instanceof ProxyError ? error.status : 503;
|
|
764
|
-
socket.end(`HTTP/1.1 ${status} Error\r\nConnection: close\r\nContent-Length: 0\r\n\r\n
|
|
770
|
+
const status = error instanceof ProxyError || error instanceof ProjectRegistryError ? error.status : 503;
|
|
771
|
+
socket.end(`HTTP/1.1 ${status} Error\r\nConnection: close\r\nContent-Length: 0\r\n\r\n`, () => socket.destroy());
|
|
765
772
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { fsyncSync, renameSync } from "node:fs";
|
|
2
2
|
import type { ProjectRuntimeRecord } from "./types.js";
|
|
3
|
-
import { type CloudAccountBinding } from "./account-binding.js";
|
|
3
|
+
import { type CloudAccountBinding, type CloudMultiAccountConfiguration } from "./account-binding.js";
|
|
4
4
|
export type ProjectStatus = "stopped" | "starting" | "running" | "stopping" | "error";
|
|
5
5
|
export interface StoredProject extends ProjectRuntimeRecord {
|
|
6
6
|
name: string;
|
|
@@ -38,6 +38,8 @@ export declare class ProjectRegistry {
|
|
|
38
38
|
rename?: typeof renameSync;
|
|
39
39
|
fsync?: typeof fsyncSync;
|
|
40
40
|
offlineAccountAdoption?: boolean;
|
|
41
|
+
/** Explicit startup policy; never inferred from the first authenticated user. */
|
|
42
|
+
multiAccountOrigin?: string;
|
|
41
43
|
});
|
|
42
44
|
/** Release the single-controller lease on shutdown or startup failure. */
|
|
43
45
|
close(): void;
|
|
@@ -45,6 +47,10 @@ export declare class ProjectRegistry {
|
|
|
45
47
|
renewInstallationForRestore(): void;
|
|
46
48
|
get installationId(): string;
|
|
47
49
|
get accountBinding(): CloudAccountBinding | undefined;
|
|
50
|
+
get multiAccount(): CloudMultiAccountConfiguration | undefined;
|
|
51
|
+
/** Called only after trusted authority verification, never with browser-selected identity. */
|
|
52
|
+
ensureAccountOwner(accountOrigin: string, accountId: string): string;
|
|
53
|
+
private requireConfiguredMode;
|
|
48
54
|
/** Offline migration only; the caller must durably preserve source evidence first. */
|
|
49
55
|
bindAccount(binding: CloudAccountBinding): void;
|
|
50
56
|
private validate;
|
|
@@ -60,6 +66,7 @@ export declare class ProjectRegistry {
|
|
|
60
66
|
private requireLegacy;
|
|
61
67
|
private requireAccountOwner;
|
|
62
68
|
private createForOwner;
|
|
69
|
+
private createForAccountOwner;
|
|
63
70
|
update(id: string, patch: {
|
|
64
71
|
status: ProjectStatus;
|
|
65
72
|
generation?: number;
|
|
@@ -2,7 +2,7 @@ import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
|
2
2
|
import { constants, chmodSync, closeSync, existsSync, fstatSync, fsyncSync, lstatSync, mkdirSync, openSync, readSync, renameSync, rmSync, writeFileSync, } from "node:fs";
|
|
3
3
|
import { join, resolve } from "node:path";
|
|
4
4
|
import { acquireProjectControllerLock } from "./lock.js";
|
|
5
|
-
import { CLOUD_ADOPTION_PENDING, isCloudAccountBinding, } from "./account-binding.js";
|
|
5
|
+
import { CLOUD_ADOPTION_PENDING, isCloudAccountBinding, isCloudAccountOrigin, isCloudMultiAccountConfiguration, } from "./account-binding.js";
|
|
6
6
|
const UUID = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/;
|
|
7
7
|
const TOKEN = /^[A-Za-z0-9_-]{43}$/;
|
|
8
8
|
const STATUSES = new Set(["stopped", "starting", "running", "stopping", "error"]);
|
|
@@ -63,6 +63,12 @@ export class ProjectRegistry {
|
|
|
63
63
|
constructor(dataDir, now = Date.now, io = {}) {
|
|
64
64
|
this.now = now;
|
|
65
65
|
this.io = io;
|
|
66
|
+
if (this.io.multiAccountOrigin !== undefined &&
|
|
67
|
+
(!isCloudAccountOrigin(this.io.multiAccountOrigin) || this.io.offlineAccountAdoption))
|
|
68
|
+
throw new Error("Multi-account Cloud requires one explicit canonical Account origin.");
|
|
69
|
+
if (this.io.multiAccountOrigin &&
|
|
70
|
+
lstatSync(join(resolve(dataDir), "hub"), { throwIfNoEntry: false }))
|
|
71
|
+
throw new Error("Multi-account Cloud requires a fresh installation without legacy Hub authority.");
|
|
66
72
|
this.directory = resolve(dataDir, "project-control");
|
|
67
73
|
const existed = existsSync(this.directory);
|
|
68
74
|
mkdirSync(this.directory, { recursive: true, mode: 0o700 });
|
|
@@ -93,12 +99,26 @@ export class ProjectRegistry {
|
|
|
93
99
|
const bytes = privateFileBytes(this.file);
|
|
94
100
|
this.data = JSON.parse(bytes.toString("utf8"));
|
|
95
101
|
this.validate();
|
|
102
|
+
this.requireConfiguredMode();
|
|
96
103
|
this.diskDigest = digest(bytes);
|
|
97
104
|
}
|
|
98
105
|
else {
|
|
99
106
|
if (existsSync(this.marker) || existed)
|
|
100
107
|
throw new Error("Project registry is missing; restore the existing registry before starting");
|
|
101
|
-
this.data = {
|
|
108
|
+
this.data = {
|
|
109
|
+
version: this.io.multiAccountOrigin ? 3 : 1,
|
|
110
|
+
installationId: randomUUID(),
|
|
111
|
+
owners: [],
|
|
112
|
+
projects: [],
|
|
113
|
+
...(this.io.multiAccountOrigin
|
|
114
|
+
? {
|
|
115
|
+
multiAccount: {
|
|
116
|
+
accountOrigin: this.io.multiAccountOrigin,
|
|
117
|
+
managementEpoch: randomUUID(),
|
|
118
|
+
},
|
|
119
|
+
}
|
|
120
|
+
: {}),
|
|
121
|
+
};
|
|
102
122
|
this.write();
|
|
103
123
|
}
|
|
104
124
|
if (!this.markerEstablished) {
|
|
@@ -123,6 +143,8 @@ export class ProjectRegistry {
|
|
|
123
143
|
next.installationId = randomUUID();
|
|
124
144
|
if (next.accountBinding)
|
|
125
145
|
next.accountBinding.managementEpoch = randomUUID();
|
|
146
|
+
if (next.multiAccount)
|
|
147
|
+
next.multiAccount.managementEpoch = randomUUID();
|
|
126
148
|
for (const project of next.projects) {
|
|
127
149
|
if (project.generation > 0)
|
|
128
150
|
project.generation += 1;
|
|
@@ -140,6 +162,40 @@ export class ProjectRegistry {
|
|
|
140
162
|
this.assertStorage();
|
|
141
163
|
return this.data.accountBinding && { ...this.data.accountBinding };
|
|
142
164
|
}
|
|
165
|
+
get multiAccount() {
|
|
166
|
+
this.assertStorage();
|
|
167
|
+
return this.data.multiAccount && { ...this.data.multiAccount };
|
|
168
|
+
}
|
|
169
|
+
/** Called only after trusted authority verification, never with browser-selected identity. */
|
|
170
|
+
ensureAccountOwner(accountOrigin, accountId) {
|
|
171
|
+
this.assertStorage();
|
|
172
|
+
if (this.data.version !== 3 ||
|
|
173
|
+
accountOrigin !== this.data.multiAccount?.accountOrigin ||
|
|
174
|
+
typeof accountId !== "string" ||
|
|
175
|
+
!UUID.test(accountId))
|
|
176
|
+
throw new ProjectRegistryError(403, "此账号没有工作台权限。");
|
|
177
|
+
const existing = this.data.owners.find((owner) => "accountId" in owner &&
|
|
178
|
+
owner.accountOrigin === accountOrigin &&
|
|
179
|
+
owner.accountId === accountId);
|
|
180
|
+
if (existing)
|
|
181
|
+
return existing.id;
|
|
182
|
+
if (this.data.owners.length >= 128)
|
|
183
|
+
throw new ProjectRegistryError(429, "工作台账号数量已达到上限。");
|
|
184
|
+
const next = structuredClone(this.data), ownerId = randomUUID();
|
|
185
|
+
next.owners.push({ id: ownerId, accountOrigin, accountId });
|
|
186
|
+
this.write(next);
|
|
187
|
+
this.data = next;
|
|
188
|
+
return ownerId;
|
|
189
|
+
}
|
|
190
|
+
requireConfiguredMode() {
|
|
191
|
+
if (this.data.version === 3) {
|
|
192
|
+
if (this.io.multiAccountOrigin !== this.data.multiAccount?.accountOrigin)
|
|
193
|
+
throw new Error("Multi-account Cloud requires its configured Account origin; automatic mode adoption is refused.");
|
|
194
|
+
}
|
|
195
|
+
else if (this.io.multiAccountOrigin !== undefined) {
|
|
196
|
+
throw new Error("Multi-account Cloud requires a fresh installation; legacy adoption is refused.");
|
|
197
|
+
}
|
|
198
|
+
}
|
|
143
199
|
/** Offline migration only; the caller must durably preserve source evidence first. */
|
|
144
200
|
bindAccount(binding) {
|
|
145
201
|
this.assertStorage();
|
|
@@ -160,23 +216,47 @@ export class ProjectRegistry {
|
|
|
160
216
|
}
|
|
161
217
|
validate(data = this.data) {
|
|
162
218
|
if (!data ||
|
|
163
|
-
|
|
219
|
+
![1, 2, 3].includes(data.version) ||
|
|
164
220
|
!UUID.test(data.installationId) ||
|
|
165
221
|
!Array.isArray(data.owners) ||
|
|
166
222
|
!Array.isArray(data.projects) ||
|
|
167
223
|
data.owners.length > 128 ||
|
|
168
224
|
data.projects.length > 128)
|
|
169
225
|
throw new Error("Project registry is corrupt; restore its backup");
|
|
170
|
-
if ((data.version
|
|
226
|
+
if ((data.version !== 3 && data.multiAccount !== undefined) ||
|
|
227
|
+
(data.version === 1 && data.accountBinding !== undefined) ||
|
|
171
228
|
(data.version === 2 &&
|
|
172
229
|
(!isCloudAccountBinding(data.accountBinding) ||
|
|
173
230
|
data.owners.length !== 1 ||
|
|
174
|
-
data.owners[0]?.id !== data.accountBinding.ownerId))
|
|
231
|
+
data.owners[0]?.id !== data.accountBinding.ownerId)) ||
|
|
232
|
+
(data.version === 3 &&
|
|
233
|
+
(data.accountBinding !== undefined ||
|
|
234
|
+
!isCloudMultiAccountConfiguration(data.multiAccount) ||
|
|
235
|
+
typeof data.installationId !== "string" ||
|
|
236
|
+
Object.keys(data).sort().join() !==
|
|
237
|
+
"installationId,multiAccount,owners,projects,version")))
|
|
175
238
|
throw new Error("Cloud account ownership is missing or corrupt; restore its backup.");
|
|
176
239
|
const owners = new Set();
|
|
177
240
|
const names = new Set();
|
|
178
241
|
for (const owner of data.owners) {
|
|
242
|
+
if (data.version === 3) {
|
|
243
|
+
if (!owner ||
|
|
244
|
+
typeof owner.id !== "string" ||
|
|
245
|
+
!UUID.test(owner.id) ||
|
|
246
|
+
owners.has(owner.id) ||
|
|
247
|
+
!("accountId" in owner) ||
|
|
248
|
+
Object.keys(owner).sort().join() !== "accountId,accountOrigin,id" ||
|
|
249
|
+
owner.accountOrigin !== data.multiAccount.accountOrigin ||
|
|
250
|
+
typeof owner.accountId !== "string" ||
|
|
251
|
+
!UUID.test(owner.accountId) ||
|
|
252
|
+
names.has(owner.accountId))
|
|
253
|
+
throw new Error("Project account owner registry is corrupt");
|
|
254
|
+
owners.add(owner.id);
|
|
255
|
+
names.add(owner.accountId);
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
179
258
|
if (!owner ||
|
|
259
|
+
!("username" in owner) ||
|
|
180
260
|
!UUID.test(owner.id) ||
|
|
181
261
|
typeof owner.username !== "string" ||
|
|
182
262
|
!owner.username.trim() ||
|
|
@@ -191,6 +271,27 @@ export class ProjectRegistry {
|
|
|
191
271
|
}
|
|
192
272
|
const ids = new Set();
|
|
193
273
|
for (const project of data.projects) {
|
|
274
|
+
if (data.version === 3 &&
|
|
275
|
+
(!project ||
|
|
276
|
+
typeof project.id !== "string" ||
|
|
277
|
+
typeof project.ownerId !== "string" ||
|
|
278
|
+
typeof project.runtimePassword !== "string" ||
|
|
279
|
+
Object.keys(project).sort().join() !==
|
|
280
|
+
[
|
|
281
|
+
"id",
|
|
282
|
+
"ownerId",
|
|
283
|
+
"generation",
|
|
284
|
+
"runtimeUsername",
|
|
285
|
+
"runtimePassword",
|
|
286
|
+
"name",
|
|
287
|
+
"status",
|
|
288
|
+
"createdAt",
|
|
289
|
+
"updatedAt",
|
|
290
|
+
...(project.error === undefined ? [] : ["error"]),
|
|
291
|
+
]
|
|
292
|
+
.sort()
|
|
293
|
+
.join()))
|
|
294
|
+
throw new Error("Project account runtime registry is corrupt");
|
|
194
295
|
if (!project ||
|
|
195
296
|
!UUID.test(project.id) ||
|
|
196
297
|
ids.has(project.id) ||
|
|
@@ -273,13 +374,13 @@ export class ProjectRegistry {
|
|
|
273
374
|
list(username) {
|
|
274
375
|
this.assertStorage();
|
|
275
376
|
this.requireLegacy();
|
|
276
|
-
const owner = this.data.owners.find((item) => item.username === username);
|
|
377
|
+
const owner = this.data.owners.find((item) => "username" in item && item.username === username);
|
|
277
378
|
return this.data.projects.filter((item) => item.ownerId === owner?.id).map(projectView);
|
|
278
379
|
}
|
|
279
380
|
get(username, id) {
|
|
280
381
|
this.assertStorage();
|
|
281
382
|
this.requireLegacy();
|
|
282
|
-
const owner = this.data.owners.find((item) => item.username === username);
|
|
383
|
+
const owner = this.data.owners.find((item) => "username" in item && item.username === username);
|
|
283
384
|
const project = this.data.projects.find((item) => item.id === id && item.ownerId === owner?.id);
|
|
284
385
|
if (!project)
|
|
285
386
|
throw new ProjectRegistryError(404, "找不到这个项目。");
|
|
@@ -303,6 +404,8 @@ export class ProjectRegistry {
|
|
|
303
404
|
}
|
|
304
405
|
createAccount(ownerId, name) {
|
|
305
406
|
this.requireAccountOwner(ownerId);
|
|
407
|
+
if (this.data.version === 3)
|
|
408
|
+
return this.createForAccountOwner(ownerId, name);
|
|
306
409
|
// This username is internal legacy metadata, never the Account display name.
|
|
307
410
|
return this.createForOwner(this.data.owners[0].username, name);
|
|
308
411
|
}
|
|
@@ -312,8 +415,10 @@ export class ProjectRegistry {
|
|
|
312
415
|
}
|
|
313
416
|
requireAccountOwner(ownerId) {
|
|
314
417
|
this.assertStorage();
|
|
315
|
-
if (this.data.version
|
|
316
|
-
|
|
418
|
+
if ((this.data.version === 2 && this.data.accountBinding?.ownerId === ownerId) ||
|
|
419
|
+
(this.data.version === 3 && this.data.owners.some((owner) => owner.id === ownerId)))
|
|
420
|
+
return;
|
|
421
|
+
throw new ProjectRegistryError(403, "此账号没有 Cloud 管理权限。");
|
|
317
422
|
}
|
|
318
423
|
createForOwner(username, name) {
|
|
319
424
|
if (typeof name !== "string" ||
|
|
@@ -330,7 +435,7 @@ export class ProjectRegistry {
|
|
|
330
435
|
if (this.data.projects.length >= 32)
|
|
331
436
|
throw new ProjectRegistryError(429, "项目数量已达到上限(32 个)。");
|
|
332
437
|
const next = structuredClone(this.data);
|
|
333
|
-
let owner = next.owners.find((item) => item.username === username);
|
|
438
|
+
let owner = next.owners.find((item) => "username" in item && item.username === username);
|
|
334
439
|
if (!owner) {
|
|
335
440
|
owner = { id: randomUUID(), username };
|
|
336
441
|
next.owners.push(owner);
|
|
@@ -352,6 +457,30 @@ export class ProjectRegistry {
|
|
|
352
457
|
this.data = next;
|
|
353
458
|
return { ...project };
|
|
354
459
|
}
|
|
460
|
+
createForAccountOwner(ownerId, name) {
|
|
461
|
+
if (typeof name !== "string" ||
|
|
462
|
+
!name.trim() ||
|
|
463
|
+
name.trim().length > 80 ||
|
|
464
|
+
/[\x00-\x1f\x7f]/.test(name))
|
|
465
|
+
throw new ProjectRegistryError(400, "请输入 1 至 80 个字符的项目名称。");
|
|
466
|
+
if (this.data.projects.length >= 128)
|
|
467
|
+
throw new ProjectRegistryError(429, "工作台项目数量已达到上限(128 个)。");
|
|
468
|
+
const next = structuredClone(this.data), timestamp = this.now(), project = {
|
|
469
|
+
id: randomUUID(),
|
|
470
|
+
ownerId,
|
|
471
|
+
name: name.trim(),
|
|
472
|
+
status: "stopped",
|
|
473
|
+
generation: 0,
|
|
474
|
+
runtimeUsername: "project-runtime",
|
|
475
|
+
runtimePassword: randomBytes(32).toString("base64url"),
|
|
476
|
+
createdAt: timestamp,
|
|
477
|
+
updatedAt: timestamp,
|
|
478
|
+
};
|
|
479
|
+
next.projects.push(project);
|
|
480
|
+
this.write(next);
|
|
481
|
+
this.data = next;
|
|
482
|
+
return { ...project };
|
|
483
|
+
}
|
|
355
484
|
update(id, patch) {
|
|
356
485
|
this.assertStorage();
|
|
357
486
|
const index = this.data.projects.findIndex((item) => item.id === id);
|
|
@@ -594,6 +594,20 @@ export async function startHeadlessServer(opts) {
|
|
|
594
594
|
hubJson(res, 200, { status: "ok", mode: hubAuth ? "hub" : "passcode" });
|
|
595
595
|
return;
|
|
596
596
|
}
|
|
597
|
+
// The public shell probes this before login. A standalone workspace has no
|
|
598
|
+
// Cloud browser authority; an explicit miss selects its existing Hub gate.
|
|
599
|
+
if (pathname === "/api/v1/cloud-browser/capabilities") {
|
|
600
|
+
if (req.url !== pathname) {
|
|
601
|
+
hubJson(res, 400, { error: "request parameters are not supported" });
|
|
602
|
+
}
|
|
603
|
+
else if (req.method !== "GET") {
|
|
604
|
+
hubJson(res, 405, { error: "method not allowed" });
|
|
605
|
+
}
|
|
606
|
+
else {
|
|
607
|
+
hubJson(res, 404, { error: "API endpoint not found" });
|
|
608
|
+
}
|
|
609
|
+
return;
|
|
610
|
+
}
|
|
597
611
|
if (hubAuth) {
|
|
598
612
|
if (await links?.handlePublic(req, res))
|
|
599
613
|
return;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cjhyy/code-shell-server",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.39",
|
|
4
4
|
"description": "CodeShell server transport layer — mobile remote HTTP/WS host, pairing, passcode gate, tunnel, rooms, uploads, and session/attachment services. Electron-free; precursor of the auth gateway.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -61,13 +61,13 @@
|
|
|
61
61
|
"clean": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\""
|
|
62
62
|
},
|
|
63
63
|
"dependencies": {
|
|
64
|
-
"@cjhyy/code-shell-capability-coding": "0.9.
|
|
65
|
-
"@cjhyy/code-shell-core": "0.9.
|
|
66
|
-
"@cjhyy/code-shell-web": "0.9.
|
|
64
|
+
"@cjhyy/code-shell-capability-coding": "0.9.39",
|
|
65
|
+
"@cjhyy/code-shell-core": "0.9.39",
|
|
66
|
+
"@cjhyy/code-shell-web": "0.9.39",
|
|
67
67
|
"@modelcontextprotocol/sdk": "^1.31.0",
|
|
68
68
|
"@types/ws": "^8.18.1",
|
|
69
69
|
"ws": "^8.21.0",
|
|
70
|
-
"@cjhyy/code-shell-link": "0.9.
|
|
70
|
+
"@cjhyy/code-shell-link": "0.9.39"
|
|
71
71
|
},
|
|
72
72
|
"engines": {
|
|
73
73
|
"node": ">=20.10"
|