@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.
@@ -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" || !/^[a-zA-Z0-9_-]{1,128}$/.test(sessionId)) {
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) => {
@@ -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";
@@ -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
- const auth = new HubAuthStore({ dataDir: source });
273
- if (!auth.isInitialized())
274
- throw new Error("Complete administrator setup before backing up.");
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
- await copyControl(source, join(destination, "control"), manifest.control);
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 !== 1 ||
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, { fsync: options.io?.fsync });
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
- const auth = new HubAuthStore({ dataDir: destination });
478
- if (!auth.isInitialized())
479
- throw new Error("Backup administrator is not initialized.");
480
- auth.revokeAllSessions();
481
- // HubAuthStore publishes by rename. Persist the replacement and its directory
482
- // before a new namespace or removal of the restore barrier can become visible.
483
- sync(join(destination, "hub/auth.json"));
484
- sync(join(destination, "hub"));
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
- // The revoked browser's transports are already closed. If its inner
98
- // session cannot be revoked, stop the single administrator's runtimes.
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
- await manager?.stopAll();
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
- if (!!binding !== !!options.accountAuthority || (binding && !options.publicOrigin))
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 = binding
111
- ? createCloudAccountAuth({
112
- binding,
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
- : legacyProjectAuthority(await createHubAuth({
119
- dataDir: options.dataDir,
120
- publicOrigin: options.publicOrigin,
121
- onRevoke,
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 = bounded(options.limits?.memoryMb ?? 2048, 128, 262144, "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 ? error.message : "Project runtime unavailable",
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 = { version: 1, installationId: randomUUID(), owners: [], projects: [] };
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
- (data.version !== 1 && data.version !== 2) ||
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 === 1 && data.accountBinding !== undefined) ||
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 !== 2 || this.data.accountBinding?.ownerId !== ownerId)
316
- throw new ProjectRegistryError(403, "此账号没有 Cloud 管理权限。");
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.37",
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.37",
65
- "@cjhyy/code-shell-core": "0.9.37",
66
- "@cjhyy/code-shell-web": "0.9.37",
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.37"
70
+ "@cjhyy/code-shell-link": "0.9.39"
71
71
  },
72
72
  "engines": {
73
73
  "node": ">=20.10"