@cjhyy/code-shell-server 0.9.36 → 0.9.37

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.
@@ -0,0 +1,287 @@
1
+ import { createHash, randomBytes, randomUUID } from "node:crypto";
2
+ import { isCloudAccountOrigin } from "./account-binding.js";
3
+ import { ProjectRegistryError } from "./registry.js";
4
+ export const CLOUD_MANAGEMENT_AUTHORITY_VERSION = 2;
5
+ export const CLOUD_MANAGEMENT_COOKIE = "cs_cloud_management";
6
+ const TOKEN = /^[A-Za-z0-9_-]{43}$/;
7
+ const UUID = /^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/;
8
+ const hash = (token) => createHash("sha256").update(token).digest("hex");
9
+ export function cloudManagementSessionView(session) {
10
+ const { ownerId: _ownerId, ...view } = session;
11
+ return view;
12
+ }
13
+ function principalValid(value) {
14
+ return (value &&
15
+ Object.keys(value).sort().join() ===
16
+ ["accountOrigin", "accountId", "sessionId", "rootSessionId", "authorityEpoch", "expiresAt"]
17
+ .sort()
18
+ .join() &&
19
+ isCloudAccountOrigin(value.accountOrigin) &&
20
+ UUID.test(value.accountId) &&
21
+ UUID.test(value.sessionId) &&
22
+ value.sessionId === value.rootSessionId &&
23
+ UUID.test(value.authorityEpoch) &&
24
+ Number.isSafeInteger(value.expiresAt));
25
+ }
26
+ /** An explicitly bound Account controls one existing single-operator installation. */
27
+ export function createCloudAccountAuth(options) {
28
+ const now = options.now ?? Date.now;
29
+ if (!isCloudAccountOrigin(options.publicOrigin) ||
30
+ options.authority.accountOrigin !== options.binding.accountOrigin ||
31
+ !UUID.test(options.installationId))
32
+ throw new Error("Cloud authority configuration does not match explicit ownership.");
33
+ const sessions = new Map();
34
+ const reading = new Set();
35
+ let closed = false;
36
+ let exchanging = 0;
37
+ let attempts = { until: now() + 60_000, count: 0 };
38
+ function revoke(entry) {
39
+ if (entry.revoked)
40
+ return;
41
+ entry.revoked = true;
42
+ options.onRevoke(entry.session.id);
43
+ }
44
+ function entryFor(session) {
45
+ if (closed || !("kind" in session))
46
+ return;
47
+ const entry = sessions.get(session.id);
48
+ if (!entry || entry.revoked || entry.session.expiresAt <= now()) {
49
+ if (entry)
50
+ revoke(entry);
51
+ return;
52
+ }
53
+ // Do not let a caller change owner, epoch or deadline using an existing id.
54
+ if (JSON.stringify(entry.session) !== JSON.stringify(session))
55
+ return;
56
+ return entry;
57
+ }
58
+ async function revalidate(session) {
59
+ const entry = entryFor(session);
60
+ if (!entry)
61
+ throw new ProjectRegistryError(401, "Cloud 管理登录已失效。");
62
+ try {
63
+ const current = await options.authority.validate({ ...entry.principal }, options.installationId);
64
+ if (!principalValid(current) ||
65
+ Object.keys(current).some((key) => current[key] !==
66
+ entry.principal[key]) ||
67
+ !entryFor(session))
68
+ throw new Error("Cloud Account authority changed.");
69
+ }
70
+ catch {
71
+ revoke(entry);
72
+ throw new ProjectRegistryError(401, "Cloud 管理授权已失效,请重新登录云账号。");
73
+ }
74
+ }
75
+ function requestToken(req) {
76
+ let authorizations = 0;
77
+ for (let i = 0; i < req.rawHeaders.length; i += 2)
78
+ if (req.rawHeaders[i].toLowerCase() === "authorization")
79
+ authorizations++;
80
+ if (authorizations > 1)
81
+ return;
82
+ const bearer = req.headers.authorization;
83
+ const cookie = req.headers.cookie
84
+ ?.split(";")
85
+ .map((part) => part.trim())
86
+ .filter((part) => part.startsWith(`${CLOUD_MANAGEMENT_COOKIE}=`));
87
+ if (bearer !== undefined && cookie?.length)
88
+ return;
89
+ if (bearer !== undefined)
90
+ return /^Bearer ([A-Za-z0-9_-]{43})$/.exec(bearer)?.[1];
91
+ if (cookie?.length === 1)
92
+ return cookie[0].slice(CLOUD_MANAGEMENT_COOKIE.length + 1);
93
+ }
94
+ function isOriginAllowed(req) {
95
+ let hosts = 0;
96
+ for (let i = 0; i < req.rawHeaders.length; i += 2)
97
+ if (req.rawHeaders[i].toLowerCase() === "host")
98
+ hosts++;
99
+ if (hosts !== 1 ||
100
+ req.headers.host !== new URL(options.publicOrigin).host ||
101
+ req.headers["sec-fetch-site"] === "cross-site")
102
+ return false;
103
+ return req.headers.origin === undefined || req.headers.origin === options.publicOrigin;
104
+ }
105
+ async function authenticate(req) {
106
+ if (!isOriginAllowed(req))
107
+ return null;
108
+ const token = requestToken(req);
109
+ if (!token || !TOKEN.test(token))
110
+ return null;
111
+ const entry = [...sessions.values()].find((row) => row.hash === hash(token));
112
+ if (!entry || !entryFor(entry.session))
113
+ return null;
114
+ await revalidate(entry.session);
115
+ return { ...entry.session };
116
+ }
117
+ function json(res, status, value) {
118
+ res.writeHead(status, {
119
+ "Content-Type": "application/json",
120
+ "Cache-Control": "no-store",
121
+ "Referrer-Policy": "no-referrer",
122
+ "X-Content-Type-Options": "nosniff",
123
+ });
124
+ res.end(JSON.stringify(value));
125
+ }
126
+ async function readBody(req) {
127
+ if (!/^application\/json(?:\s*;|$)/i.test(req.headers["content-type"] ?? ""))
128
+ throw new ProjectRegistryError(415, "请使用 JSON 提交。");
129
+ const chunks = [];
130
+ let size = 0;
131
+ const timer = setTimeout(() => req.destroy(), 5000);
132
+ timer.unref();
133
+ reading.add(req);
134
+ try {
135
+ for await (const chunk of req) {
136
+ size += chunk.length;
137
+ if (size > 4096)
138
+ throw new ProjectRegistryError(413, "请求过大。");
139
+ chunks.push(Buffer.from(chunk));
140
+ }
141
+ }
142
+ finally {
143
+ clearTimeout(timer);
144
+ reading.delete(req);
145
+ }
146
+ try {
147
+ const body = JSON.parse(Buffer.concat(chunks).toString("utf8"));
148
+ if (body && typeof body === "object" && !Array.isArray(body))
149
+ return body;
150
+ }
151
+ catch {
152
+ /* fixed public error below */
153
+ }
154
+ throw new ProjectRegistryError(400, "请求格式无效。");
155
+ }
156
+ async function handle(req, res) {
157
+ const path = (req.url ?? "/").split("?", 1)[0];
158
+ if (!path.startsWith("/api/v1/cloud-management/") && !path.startsWith("/api/v1/auth/"))
159
+ return false;
160
+ if (!isOriginAllowed(req))
161
+ throw new ProjectRegistryError(403, "请求来源无效。");
162
+ if (closed)
163
+ throw new ProjectRegistryError(503, "Cloud 管理服务正在关闭。");
164
+ if (req.url !== path)
165
+ throw new ProjectRegistryError(400, "请求参数无效。");
166
+ if (path === "/api/v1/cloud-management/capabilities" && req.method === "GET") {
167
+ json(res, 200, {
168
+ version: CLOUD_MANAGEMENT_AUTHORITY_VERSION,
169
+ mode: "cloud-account",
170
+ cloudOrigin: options.publicOrigin,
171
+ installationId: options.installationId,
172
+ accountOrigin: options.binding.accountOrigin,
173
+ });
174
+ return true;
175
+ }
176
+ if (path === "/api/v1/cloud-management/session" && req.method === "POST") {
177
+ // Main sends an Account-issued target-bound single-use ticket, never its root credential.
178
+ if (req.headers.origin !== undefined ||
179
+ req.headers.cookie !== undefined ||
180
+ req.headers.authorization !== undefined ||
181
+ req.headers["sec-fetch-site"] !== undefined)
182
+ throw new ProjectRegistryError(403, "请从桌面 Cloud 管理器登录。");
183
+ if (now() >= attempts.until)
184
+ attempts = { until: now() + 60_000, count: 0 };
185
+ if (++attempts.count > 60 || exchanging >= 4)
186
+ throw new ProjectRegistryError(429, "Cloud 管理登录请求过多。");
187
+ exchanging++;
188
+ try {
189
+ const body = await readBody(req);
190
+ if (Object.keys(body).join() !== "ticket" ||
191
+ typeof body.ticket !== "string" ||
192
+ !TOKEN.test(body.ticket))
193
+ throw new ProjectRegistryError(400, "Cloud 管理票据格式无效。");
194
+ let principal;
195
+ try {
196
+ principal = await options.authority.redeem(body.ticket, options.installationId);
197
+ }
198
+ catch {
199
+ throw new ProjectRegistryError(401, "云账号验证失败。");
200
+ }
201
+ if (closed ||
202
+ !principalValid(principal) ||
203
+ principal.accountOrigin !== options.binding.accountOrigin ||
204
+ principal.accountId !== options.binding.accountId ||
205
+ principal.expiresAt <= now())
206
+ throw new ProjectRegistryError(403, "此账号没有 Cloud 管理权限。");
207
+ for (const [id, row] of sessions)
208
+ if (row.session.expiresAt + 600_000 <= now())
209
+ sessions.delete(id);
210
+ if (sessions.size >= 128)
211
+ throw new ProjectRegistryError(429, "Cloud 管理会话数量已达上限。");
212
+ const token = randomBytes(32).toString("base64url");
213
+ const session = {
214
+ id: randomUUID(),
215
+ kind: "cloud-management",
216
+ audience: options.publicOrigin,
217
+ installationId: options.installationId,
218
+ accountOrigin: principal.accountOrigin,
219
+ accountId: principal.accountId,
220
+ managementEpoch: options.binding.managementEpoch,
221
+ expiresAt: Math.min(now() + 600_000, principal.expiresAt),
222
+ ownerId: options.binding.ownerId,
223
+ };
224
+ sessions.set(session.id, {
225
+ hash: hash(token),
226
+ session,
227
+ principal: { ...principal },
228
+ revoked: false,
229
+ });
230
+ json(res, 201, { token, session: cloudManagementSessionView(session) });
231
+ }
232
+ finally {
233
+ exchanging--;
234
+ }
235
+ return true;
236
+ }
237
+ if (path === "/api/v1/auth/status" && req.method === "GET") {
238
+ const session = await authenticate(req);
239
+ json(res, 200, {
240
+ initialized: true,
241
+ mode: "cloud-account",
242
+ authenticated: !!session,
243
+ ...(session && "kind" in session ? { session: cloudManagementSessionView(session) } : {}),
244
+ });
245
+ return true;
246
+ }
247
+ if (["/api/v1/cloud-management/logout", "/api/v1/auth/logout"].includes(path) &&
248
+ req.method === "POST") {
249
+ const body = await readBody(req);
250
+ if (Object.keys(body).length)
251
+ throw new ProjectRegistryError(400, "退出不接受额外参数。");
252
+ const token = requestToken(req);
253
+ const entry = token &&
254
+ TOKEN.test(token) &&
255
+ [...sessions.values()].find((row) => row.hash === hash(token));
256
+ if (!entry)
257
+ throw new ProjectRegistryError(401, "Cloud 管理登录已失效。");
258
+ revoke(entry);
259
+ res.setHeader("Set-Cookie", `${CLOUD_MANAGEMENT_COOKIE}=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0${options.publicOrigin.startsWith("https:") ? "; Secure" : ""}`);
260
+ json(res, 200, { revoked: true });
261
+ return true;
262
+ }
263
+ throw new ProjectRegistryError(403, "此 Cloud 只接受明确绑定的云账号管理。");
264
+ }
265
+ return {
266
+ cookieName: CLOUD_MANAGEMENT_COOKIE,
267
+ store: {
268
+ listSessions: () => [...sessions.values()]
269
+ .filter((row) => entryFor(row.session))
270
+ .map((row) => ({ ...row.session })),
271
+ },
272
+ handle,
273
+ authenticate,
274
+ isOriginAllowed,
275
+ isActive: (session) => !!entryFor(session),
276
+ revalidate,
277
+ close() {
278
+ closed = true;
279
+ for (const request of reading)
280
+ request.destroy();
281
+ reading.clear();
282
+ for (const row of sessions.values())
283
+ revoke(row);
284
+ sessions.clear();
285
+ },
286
+ };
287
+ }
@@ -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;
@@ -12,6 +13,8 @@ export interface ProjectControlServerOptions {
12
13
  runtimeSeccompProfile?: string;
13
14
  /** Dependency injection for lifecycle/transport integration tests. */
14
15
  provider?: ProjectRuntimeProvider;
16
+ /** Trusted fixed Account-origin verifier. Requires explicit offline ownership adoption. */
17
+ accountAuthority?: CloudAccountAuthority;
15
18
  }
16
19
  /** A small account/project gateway. It never creates a worker, executes tools or mounts cwd. */
17
20
  export declare function startProjectControlServer(options: ProjectControlServerOptions): Promise<{
@@ -1,10 +1,12 @@
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 { legacyProjectAuthority } from "./control-authority.js";
8
10
  import { resolveSafe } from "../mobile-remote/mobile-static.js";
9
11
  import { contentTypeFor } from "../static-files.js";
10
12
  import { createDockerProjectProvider } from "./docker-provider.js";
@@ -69,8 +71,16 @@ async function readBody(request) {
69
71
  /** A small account/project gateway. It never creates a worker, executes tools or mounts cwd. */
70
72
  export async function startProjectControlServer(options) {
71
73
  const remoteLink = remoteLinkHostConfiguration(options.remoteLink, options.publicOrigin);
72
- if (existsSync(join(options.dataDir, "restore-in-progress")))
74
+ // Any entry is a barrier, including dangling symlinks. Only ENOENT means absent;
75
+ // permission/I/O failures must abort before registry or Hub initialization.
76
+ try {
77
+ lstatSync(join(options.dataDir, "restore-in-progress"));
73
78
  throw new Error("Cloud installation restore is incomplete; do not start this directory.");
79
+ }
80
+ catch (error) {
81
+ if (error.code !== "ENOENT")
82
+ throw error;
83
+ }
74
84
  const registry = new ProjectRegistry(options.dataDir);
75
85
  let proxy;
76
86
  let manager;
@@ -78,26 +88,38 @@ export async function startProjectControlServer(options) {
78
88
  let server;
79
89
  let shuttingDown = false;
80
90
  try {
81
- const auth = await createHubAuth({
82
- dataDir: options.dataDir,
83
- publicOrigin: options.publicOrigin,
84
- onRevoke: (sessionId) => {
91
+ const onRevoke = (sessionId) => {
92
+ if (shuttingDown)
93
+ return;
94
+ void proxy?.revokeOwner(sessionId).catch(async () => {
85
95
  if (shuttingDown)
86
96
  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 {
93
- await manager?.stopAll();
94
- }
95
- catch {
96
- console.error("[project-control] could not confirm project termination after session revocation.");
97
- }
98
- });
99
- },
100
- });
97
+ // The revoked browser's transports are already closed. If its inner
98
+ // session cannot be revoked, stop the single administrator's runtimes.
99
+ try {
100
+ await manager?.stopAll();
101
+ }
102
+ catch {
103
+ console.error("[project-control] could not confirm project termination after session revocation.");
104
+ }
105
+ });
106
+ };
107
+ const binding = registry.accountBinding;
108
+ if (!!binding !== !!options.accountAuthority || (binding && !options.publicOrigin))
109
+ throw new Error("Cloud account authority requires explicit matching ownership and public origin.");
110
+ const auth = binding
111
+ ? createCloudAccountAuth({
112
+ binding,
113
+ installationId: registry.installationId,
114
+ publicOrigin: options.publicOrigin,
115
+ authority: options.accountAuthority,
116
+ onRevoke,
117
+ })
118
+ : legacyProjectAuthority(await createHubAuth({
119
+ dataDir: options.dataDir,
120
+ publicOrigin: options.publicOrigin,
121
+ onRevoke,
122
+ }));
101
123
  let listeningOrigin = "";
102
124
  const publicOrigin = () => options.publicOrigin ?? listeningOrigin;
103
125
  provider =
@@ -113,9 +135,12 @@ export async function startProjectControlServer(options) {
113
135
  registry,
114
136
  provider,
115
137
  publicOrigin,
116
- isSessionActive: (session) => auth.store
117
- .listSessions()
118
- .some((item) => item.id === session.id && item.username === session.username),
138
+ isSessionActive: (session) => auth.isActive(session),
139
+ ...(binding
140
+ ? {
141
+ revalidateSession: (session) => auth.revalidate(session),
142
+ }
143
+ : {}),
119
144
  revokeProject: async (id) => {
120
145
  await proxy?.revokeProject(id);
121
146
  },
@@ -172,6 +197,7 @@ export async function startProjectControlServer(options) {
172
197
  throw new ProjectRegistryError(400, "项目请求参数无效。");
173
198
  if (pathname === "/api/v1/projects" && request.method === "GET") {
174
199
  const availability = await provider.availability();
200
+ await auth.revalidate(session);
175
201
  json(response, 200, {
176
202
  runtime: "docker",
177
203
  ...availability,
@@ -183,10 +209,11 @@ export async function startProjectControlServer(options) {
183
209
  const body = await readBody(request);
184
210
  if (Object.keys(body).some((key) => key !== "name"))
185
211
  throw new ProjectRegistryError(400, "仅支持设置项目名称。");
212
+ await auth.revalidate(session);
186
213
  json(response, 201, { project: projects.create(session, body.name) });
187
214
  return;
188
215
  }
189
- const match = /^\/api\/v1\/projects\/([a-f0-9-]{36})(?:\/(start|stop))?$/.exec(pathname);
216
+ const match = /^\/api\/v1\/projects\/([a-f0-9-]{36})(?:\/(start|stop|open))?$/.exec(pathname);
190
217
  if (match && request.method === "GET" && !match[2]) {
191
218
  json(response, 200, { project: projects.get(session, match[1]) });
192
219
  return;
@@ -195,6 +222,14 @@ export async function startProjectControlServer(options) {
195
222
  const body = await readBody(request);
196
223
  if (Object.keys(body).length)
197
224
  throw new ProjectRegistryError(400, "启动和停止不接受额外参数。");
225
+ if (match[2] === "open") {
226
+ await projects.resolveTarget(session, match[1]);
227
+ json(response, 200, {
228
+ project: projects.get(session, match[1]),
229
+ url: `${publicOrigin()}/?project=${match[1]}`,
230
+ });
231
+ return;
232
+ }
198
233
  const project = match[2] === "start"
199
234
  ? await projects.start(session, match[1])
200
235
  : await projects.stop(session, match[1]);
@@ -286,6 +321,7 @@ export async function startProjectControlServer(options) {
286
321
  // Revoke streams and inner sessions before stopping their servers.
287
322
  // If logout is unreachable, confirmed runtime termination is the
288
323
  // fallback; a real stop failure must still reject close().
324
+ auth.close?.();
289
325
  await transport.close().catch(() => { });
290
326
  await projects.close();
291
327
  }