@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.
- package/dist/bin/code-shell-cloud-account.d.ts +2 -0
- package/dist/bin/code-shell-cloud-account.js +53 -0
- package/dist/index.serve.d.ts +4 -0
- package/dist/index.serve.js +3 -0
- package/dist/panels/management.js +12 -1
- package/dist/panels/types.d.ts +5 -3
- package/dist/project-runtime/account-adoption.d.ts +34 -0
- package/dist/project-runtime/account-adoption.js +280 -0
- package/dist/project-runtime/account-binding.d.ts +18 -0
- package/dist/project-runtime/account-binding.js +42 -0
- package/dist/project-runtime/backup.d.ts +10 -0
- package/dist/project-runtime/backup.js +113 -20
- package/dist/project-runtime/cloud-account-auth.d.ts +23 -0
- package/dist/project-runtime/cloud-account-auth.js +287 -0
- package/dist/project-runtime/cloud-browser-auth.d.ts +15 -0
- package/dist/project-runtime/cloud-browser-auth.js +352 -0
- package/dist/project-runtime/control-authority.d.ts +46 -0
- package/dist/project-runtime/control-authority.js +15 -0
- package/dist/project-runtime/control-server.d.ts +17 -0
- package/dist/project-runtime/control-server.js +94 -26
- package/dist/project-runtime/docker-provider.d.ts +6 -0
- package/dist/project-runtime/docker-provider.js +10 -5
- package/dist/project-runtime/lock.js +181 -12
- package/dist/project-runtime/manager.d.ts +16 -8
- package/dist/project-runtime/manager.js +89 -15
- package/dist/project-runtime/proxy.d.ts +3 -3
- package/dist/project-runtime/proxy.js +15 -5
- package/dist/project-runtime/registry.d.ts +21 -1
- package/dist/project-runtime/registry.js +224 -6
- package/dist/serve/headless-server.js +14 -0
- 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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
96
|
-
|
|
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.
|
|
117
|
-
|
|
118
|
-
|
|
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;
|