@cjhyy/code-shell-server 0.9.36 → 0.9.38

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.
Files changed (31) hide show
  1. package/dist/bin/code-shell-cloud-account.d.ts +2 -0
  2. package/dist/bin/code-shell-cloud-account.js +53 -0
  3. package/dist/index.serve.d.ts +4 -0
  4. package/dist/index.serve.js +3 -0
  5. package/dist/panels/management.js +12 -1
  6. package/dist/panels/types.d.ts +5 -3
  7. package/dist/project-runtime/account-adoption.d.ts +34 -0
  8. package/dist/project-runtime/account-adoption.js +280 -0
  9. package/dist/project-runtime/account-binding.d.ts +18 -0
  10. package/dist/project-runtime/account-binding.js +42 -0
  11. package/dist/project-runtime/backup.d.ts +10 -0
  12. package/dist/project-runtime/backup.js +113 -20
  13. package/dist/project-runtime/cloud-account-auth.d.ts +23 -0
  14. package/dist/project-runtime/cloud-account-auth.js +287 -0
  15. package/dist/project-runtime/cloud-browser-auth.d.ts +15 -0
  16. package/dist/project-runtime/cloud-browser-auth.js +352 -0
  17. package/dist/project-runtime/control-authority.d.ts +46 -0
  18. package/dist/project-runtime/control-authority.js +15 -0
  19. package/dist/project-runtime/control-server.d.ts +17 -0
  20. package/dist/project-runtime/control-server.js +94 -26
  21. package/dist/project-runtime/docker-provider.d.ts +6 -0
  22. package/dist/project-runtime/docker-provider.js +10 -5
  23. package/dist/project-runtime/lock.js +181 -12
  24. package/dist/project-runtime/manager.d.ts +16 -8
  25. package/dist/project-runtime/manager.js +89 -15
  26. package/dist/project-runtime/proxy.d.ts +3 -3
  27. package/dist/project-runtime/proxy.js +15 -5
  28. package/dist/project-runtime/registry.d.ts +21 -1
  29. package/dist/project-runtime/registry.js +224 -6
  30. package/dist/serve/headless-server.js +14 -0
  31. package/package.json +7 -6
@@ -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
+ }
@@ -0,0 +1,46 @@
1
+ import type { IncomingMessage, ServerResponse } from "node:http";
2
+ import type { HubAuth } from "../hub/auth-http.js";
3
+ import type { HubSession } from "../hub/auth-store.js";
4
+ /** Public Account identity; access/refresh credentials never enter project workers. */
5
+ export interface CloudAccountPrincipal {
6
+ accountOrigin: string;
7
+ accountId: string;
8
+ sessionId: string;
9
+ rootSessionId: string;
10
+ authorityEpoch: string;
11
+ expiresAt: number;
12
+ }
13
+ export interface CloudManagementSession {
14
+ id: string;
15
+ kind: "cloud-management";
16
+ audience: string;
17
+ installationId: string;
18
+ accountOrigin: string;
19
+ accountId: string;
20
+ managementEpoch: string;
21
+ expiresAt: number;
22
+ /** Internal owner scope, excluded from every response. */
23
+ ownerId: string;
24
+ }
25
+ export type ProjectControlSession = HubSession | CloudManagementSession;
26
+ export interface ProjectControlAuthority {
27
+ bootstrapToken?: string;
28
+ cookieName: string;
29
+ store: {
30
+ listSessions(): ProjectControlSession[];
31
+ };
32
+ handle(req: IncomingMessage, res: ServerResponse): Promise<boolean>;
33
+ authenticate(req: IncomingMessage): Promise<ProjectControlSession | null>;
34
+ isOriginAllowed(req: IncomingMessage): boolean;
35
+ /** Local lifetime check, synchronous so an already revoked transport closes promptly. */
36
+ isActive(session: ProjectControlSession): boolean;
37
+ /** Continuous authority check, also called around asynchronous lifecycle work. */
38
+ revalidate(session: ProjectControlSession): Promise<void>;
39
+ close?(): void;
40
+ }
41
+ export interface CloudAccountAuthority {
42
+ accountOrigin: string;
43
+ redeem(ticket: string, installationId: string): Promise<CloudAccountPrincipal>;
44
+ validate(principal: CloudAccountPrincipal, installationId: string): Promise<CloudAccountPrincipal>;
45
+ }
46
+ export declare function legacyProjectAuthority(auth: HubAuth): ProjectControlAuthority;
@@ -0,0 +1,15 @@
1
+ export function legacyProjectAuthority(auth) {
2
+ const isActive = (session) => !("kind" in session) &&
3
+ auth.store
4
+ .listSessions()
5
+ .some((row) => row.id === session.id && row.username === session.username);
6
+ return {
7
+ ...auth,
8
+ cookieName: "cs_hub_session",
9
+ isActive,
10
+ async revalidate(session) {
11
+ if (!isActive(session))
12
+ throw new Error("Project control authority expired.");
13
+ },
14
+ };
15
+ }
@@ -1,4 +1,5 @@
1
1
  import type { RemoteLinkConfiguration } from "@cjhyy/code-shell-core";
2
+ import { type CloudAccountAuthority } from "./control-authority.js";
2
3
  import type { ProjectRuntimeProvider } from "./types.js";
3
4
  export interface ProjectControlServerOptions {
4
5
  host: string;
@@ -10,8 +11,24 @@ export interface ProjectControlServerOptions {
10
11
  runtimeImage?: string;
11
12
  /** Trusted operator configuration, never accepted from project requests. */
12
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
+ };
13
26
  /** Dependency injection for lifecycle/transport integration tests. */
14
27
  provider?: ProjectRuntimeProvider;
28
+ /** Trusted fixed Account-origin verifier. Requires explicit offline ownership adoption. */
29
+ accountAuthority?: CloudAccountAuthority;
30
+ /** Explicit fresh-install multi-account browser workspaces; never adopts old owners. */
31
+ accountMode?: "multi-account";
15
32
  }
16
33
  /** A small account/project gateway. It never creates a worker, executes tools or mounts cwd. */
17
34
  export declare function startProjectControlServer(options: ProjectControlServerOptions): Promise<{
@@ -1,13 +1,16 @@
1
1
  import { remoteLinkHostConfiguration } from "../links/remote-configuration.js";
2
2
  import { describeEnvironment } from "../environment-identity.js";
3
3
  import { createServer } from "node:http";
4
- import { existsSync, readFileSync } from "node:fs";
4
+ import { lstatSync, readFileSync } from "node:fs";
5
5
  import { join } from "node:path";
6
6
  import { extname } from "node:path";
7
7
  import { createHubAuth } from "../hub/auth-http.js";
8
+ import { createCloudAccountAuth } from "./cloud-account-auth.js";
9
+ import { createCloudBrowserAuth } from "./cloud-browser-auth.js";
10
+ import { legacyProjectAuthority } from "./control-authority.js";
8
11
  import { resolveSafe } from "../mobile-remote/mobile-static.js";
9
12
  import { contentTypeFor } from "../static-files.js";
10
- import { createDockerProjectProvider } from "./docker-provider.js";
13
+ import { createDockerProjectProvider, resolveProjectRuntimeLimits } from "./docker-provider.js";
11
14
  import { ProjectManager } from "./manager.js";
12
15
  import { createProjectRuntimeProxy } from "./proxy.js";
13
16
  import { ProjectRegistry, ProjectRegistryError } from "./registry.js";
@@ -68,36 +71,79 @@ async function readBody(request) {
68
71
  }
69
72
  /** A small account/project gateway. It never creates a worker, executes tools or mounts cwd. */
70
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.");
71
79
  const remoteLink = remoteLinkHostConfiguration(options.remoteLink, options.publicOrigin);
72
- if (existsSync(join(options.dataDir, "restore-in-progress")))
80
+ // Any entry is a barrier, including dangling symlinks. Only ENOENT means absent;
81
+ // permission/I/O failures must abort before registry or Hub initialization.
82
+ try {
83
+ lstatSync(join(options.dataDir, "restore-in-progress"));
73
84
  throw new Error("Cloud installation restore is incomplete; do not start this directory.");
74
- const registry = new ProjectRegistry(options.dataDir);
85
+ }
86
+ catch (error) {
87
+ if (error.code !== "ENOENT")
88
+ throw error;
89
+ }
90
+ const registry = new ProjectRegistry(options.dataDir, Date.now, {
91
+ ...(options.accountMode === "multi-account"
92
+ ? { multiAccountOrigin: options.accountAuthority.accountOrigin }
93
+ : {}),
94
+ });
75
95
  let proxy;
76
96
  let manager;
77
97
  let provider;
78
98
  let server;
79
99
  let shuttingDown = false;
80
100
  try {
81
- const auth = await createHubAuth({
82
- dataDir: options.dataDir,
83
- publicOrigin: options.publicOrigin,
84
- onRevoke: (sessionId) => {
101
+ const onRevoke = (sessionId, ownerId) => {
102
+ if (shuttingDown)
103
+ return;
104
+ void proxy?.revokeOwner(sessionId).catch(async () => {
85
105
  if (shuttingDown)
86
106
  return;
87
- void proxy?.revokeOwner(sessionId).catch(async () => {
88
- if (shuttingDown)
89
- return;
90
- // The revoked browser's transports are already closed. If its inner
91
- // session cannot be revoked, stop the single administrator's runtimes.
92
- try {
107
+ // Transports are already closed. If inner revocation fails, terminate
108
+ // only this Account owner's runtimes; legacy keeps its single-owner fallback.
109
+ try {
110
+ if (options.accountMode === "multi-account" && ownerId)
111
+ await manager?.stopOwner(ownerId);
112
+ else
93
113
  await manager?.stopAll();
94
- }
95
- catch {
96
- console.error("[project-control] could not confirm project termination after session revocation.");
97
- }
98
- });
99
- },
100
- });
114
+ }
115
+ catch {
116
+ console.error("[project-control] could not confirm project termination after session revocation.");
117
+ }
118
+ });
119
+ };
120
+ const binding = registry.accountBinding;
121
+ const multiAccount = registry.multiAccount;
122
+ if (!!(binding || multiAccount) !== !!options.accountAuthority ||
123
+ ((binding || multiAccount) && !options.publicOrigin))
124
+ throw new Error("Cloud account authority requires explicit matching ownership and public origin.");
125
+ const auth = multiAccount
126
+ ? createCloudBrowserAuth({
127
+ ...multiAccount,
128
+ installationId: registry.installationId,
129
+ publicOrigin: options.publicOrigin,
130
+ authority: options.accountAuthority,
131
+ ownerForAccount: (origin, id) => registry.ensureAccountOwner(origin, id),
132
+ onRevoke,
133
+ })
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
+ }));
101
147
  let listeningOrigin = "";
102
148
  const publicOrigin = () => options.publicOrigin ?? listeningOrigin;
103
149
  provider =
@@ -106,16 +152,23 @@ export async function startProjectControlServer(options) {
106
152
  installationId: registry.installationId,
107
153
  dataDir: options.dataDir,
108
154
  image: options.runtimeImage,
155
+ limits: runtimeLimits,
109
156
  seccompProfile: options.runtimeSeccompProfile,
110
157
  remoteLink,
111
158
  });
112
159
  manager = new ProjectManager({
160
+ maxRunning: options.projectLimits?.maxRunning,
161
+ maxRunningPerOwner: options.projectLimits?.maxRunningPerOwner,
162
+ maxProjectsPerOwner: options.projectLimits?.maxProjectsPerOwner,
113
163
  registry,
114
164
  provider,
115
165
  publicOrigin,
116
- isSessionActive: (session) => auth.store
117
- .listSessions()
118
- .some((item) => item.id === session.id && item.username === session.username),
166
+ isSessionActive: (session) => auth.isActive(session),
167
+ ...(binding || multiAccount
168
+ ? {
169
+ revalidateSession: (session) => auth.revalidate(session),
170
+ }
171
+ : {}),
119
172
  revokeProject: async (id) => {
120
173
  await proxy?.revokeProject(id);
121
174
  },
@@ -152,7 +205,10 @@ export async function startProjectControlServer(options) {
152
205
  return;
153
206
  if (await transport.handle(request, response))
154
207
  return;
155
- 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, "环境请求参数无效。");
156
212
  if (!auth.isOriginAllowed(request))
157
213
  throw new ProjectRegistryError(403, "请求来源无效。");
158
214
  if (!(await auth.authenticate(request)))
@@ -172,6 +228,7 @@ export async function startProjectControlServer(options) {
172
228
  throw new ProjectRegistryError(400, "项目请求参数无效。");
173
229
  if (pathname === "/api/v1/projects" && request.method === "GET") {
174
230
  const availability = await provider.availability();
231
+ await auth.revalidate(session);
175
232
  json(response, 200, {
176
233
  runtime: "docker",
177
234
  ...availability,
@@ -183,10 +240,11 @@ export async function startProjectControlServer(options) {
183
240
  const body = await readBody(request);
184
241
  if (Object.keys(body).some((key) => key !== "name"))
185
242
  throw new ProjectRegistryError(400, "仅支持设置项目名称。");
243
+ await auth.revalidate(session);
186
244
  json(response, 201, { project: projects.create(session, body.name) });
187
245
  return;
188
246
  }
189
- const match = /^\/api\/v1\/projects\/([a-f0-9-]{36})(?:\/(start|stop))?$/.exec(pathname);
247
+ const match = /^\/api\/v1\/projects\/([a-f0-9-]{36})(?:\/(start|stop|open))?$/.exec(pathname);
190
248
  if (match && request.method === "GET" && !match[2]) {
191
249
  json(response, 200, { project: projects.get(session, match[1]) });
192
250
  return;
@@ -195,6 +253,14 @@ export async function startProjectControlServer(options) {
195
253
  const body = await readBody(request);
196
254
  if (Object.keys(body).length)
197
255
  throw new ProjectRegistryError(400, "启动和停止不接受额外参数。");
256
+ if (match[2] === "open") {
257
+ await projects.resolveTarget(session, match[1]);
258
+ json(response, 200, {
259
+ project: projects.get(session, match[1]),
260
+ url: `${publicOrigin()}/?project=${match[1]}`,
261
+ });
262
+ return;
263
+ }
198
264
  const project = match[2] === "start"
199
265
  ? await projects.start(session, match[1])
200
266
  : await projects.stop(session, match[1]);
@@ -228,6 +294,7 @@ export async function startProjectControlServer(options) {
228
294
  "Content-Type": contentTypeFor(extname(file)),
229
295
  "Content-Length": bytes.length,
230
296
  "Cache-Control": "no-cache",
297
+ ...(multiAccount ? { "Content-Security-Policy": "frame-ancestors 'none'" } : {}),
231
298
  });
232
299
  response.end(request.method === "HEAD" ? undefined : bytes);
233
300
  };
@@ -286,6 +353,7 @@ export async function startProjectControlServer(options) {
286
353
  // Revoke streams and inner sessions before stopping their servers.
287
354
  // If logout is unreachable, confirmed runtime termination is the
288
355
  // fallback; a real stop failure must still reject close().
356
+ auth.close?.();
289
357
  await transport.close().catch(() => { });
290
358
  await projects.close();
291
359
  }
@@ -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;