@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
|
@@ -103,6 +103,15 @@ async function within(promise, signal) {
|
|
|
103
103
|
signal.removeEventListener("abort", onAbort);
|
|
104
104
|
}
|
|
105
105
|
}
|
|
106
|
+
/** Shared trusted configuration parsing, including hosts with an injected provider. */
|
|
107
|
+
export function resolveProjectRuntimeLimits(limits) {
|
|
108
|
+
const memoryMb = bounded(limits?.memoryMb ?? 2048, 128, 262144, "memory");
|
|
109
|
+
const cpus = bounded(limits?.cpus ?? 2, 0.1, 128, "CPU");
|
|
110
|
+
const pids = bounded(limits?.pids ?? 256, 16, 65536, "process");
|
|
111
|
+
if (!Number.isInteger(memoryMb) || !Number.isInteger(pids))
|
|
112
|
+
throw new ProjectRuntimeError("Invalid resource limits.");
|
|
113
|
+
return { memoryMb, cpus, pids };
|
|
114
|
+
}
|
|
106
115
|
/** An administrator-owned Docker control client. Project tasks never receive this client. */
|
|
107
116
|
export function createDockerProjectProvider(options) {
|
|
108
117
|
if (!UUID.test(options.installationId))
|
|
@@ -116,11 +125,7 @@ export function createDockerProjectProvider(options) {
|
|
|
116
125
|
const dataDir = resolve(options.dataDir);
|
|
117
126
|
if (/[\0,\r\n]/.test(dataDir))
|
|
118
127
|
throw new ProjectRuntimeError("Unsupported project metadata path.");
|
|
119
|
-
const memory =
|
|
120
|
-
const cpus = bounded(options.limits?.cpus ?? 2, 0.1, 128, "CPU");
|
|
121
|
-
const pids = bounded(options.limits?.pids ?? 256, 16, 65536, "process");
|
|
122
|
-
if (!Number.isInteger(memory) || !Number.isInteger(pids))
|
|
123
|
-
throw new ProjectRuntimeError("Invalid resource limits.");
|
|
128
|
+
const { memoryMb: memory, cpus, pids } = resolveProjectRuntimeLimits(options.limits);
|
|
124
129
|
const commandTimeout = bounded(options.commandTimeoutMs ?? 15000, 10, 120000, "command timeout");
|
|
125
130
|
const startupTimeout = bounded(options.startupTimeoutMs ?? 90000, 10, 300000, "startup timeout");
|
|
126
131
|
const command = options.command ?? dockerCommand;
|
|
@@ -1,43 +1,212 @@
|
|
|
1
|
-
import
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { join, resolve } from "node:path";
|
|
2
4
|
import { lockSync } from "@cjhyy/code-shell-core/internal";
|
|
3
5
|
/** One control process per directory; heartbeat leases recover after a crash. */
|
|
4
6
|
export function acquireProjectControllerLock(directory) {
|
|
5
|
-
|
|
7
|
+
directory = resolve(directory);
|
|
8
|
+
const original = fs.lstatSync(directory);
|
|
6
9
|
if (!original.isDirectory() || original.isSymbolicLink())
|
|
7
10
|
throw new Error("Unsafe project control directory.");
|
|
11
|
+
const lockPath = `${directory}.lock`;
|
|
12
|
+
let identity;
|
|
13
|
+
let candidate;
|
|
14
|
+
let owner;
|
|
15
|
+
let candidateOwner;
|
|
16
|
+
let mtime;
|
|
8
17
|
let compromised = false;
|
|
9
18
|
let released = false;
|
|
19
|
+
const sameDirectory = (actual, expected) => actual.isDirectory() &&
|
|
20
|
+
!actual.isSymbolicLink() &&
|
|
21
|
+
actual.dev === expected.dev &&
|
|
22
|
+
actual.ino === expected.ino;
|
|
23
|
+
const ownerFile = (actual) => actual.isFile() &&
|
|
24
|
+
!actual.isSymbolicLink() &&
|
|
25
|
+
actual.nlink === 1 &&
|
|
26
|
+
actual.size === 0 &&
|
|
27
|
+
(actual.mode & 0o077) === 0;
|
|
28
|
+
function readOwner() {
|
|
29
|
+
const entries = fs.readdirSync(lockPath);
|
|
30
|
+
if (!entries.length)
|
|
31
|
+
return; // A historical proper-lockfile owner had no marker.
|
|
32
|
+
if (entries.length !== 1 ||
|
|
33
|
+
!/^\.owner-[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/.test(entries[0]))
|
|
34
|
+
throw lost();
|
|
35
|
+
const identity = fs.lstatSync(join(lockPath, entries[0]));
|
|
36
|
+
if (!ownerFile(identity))
|
|
37
|
+
throw lost();
|
|
38
|
+
return { name: entries[0], identity };
|
|
39
|
+
}
|
|
40
|
+
function assertOwner(expected) {
|
|
41
|
+
const current = readOwner();
|
|
42
|
+
if (!current ||
|
|
43
|
+
current.name !== expected.name ||
|
|
44
|
+
current.identity.dev !== expected.identity.dev ||
|
|
45
|
+
current.identity.ino !== expected.identity.ino)
|
|
46
|
+
throw lost();
|
|
47
|
+
}
|
|
48
|
+
const lost = () => {
|
|
49
|
+
compromised = true;
|
|
50
|
+
// ENOENT terminates proper-lockfile's renewal immediately, rather than
|
|
51
|
+
// repeatedly retrying an owner which has already permanently lost custody.
|
|
52
|
+
return Object.assign(new Error("Project controller lock is no longer held."), {
|
|
53
|
+
code: "ENOENT",
|
|
54
|
+
});
|
|
55
|
+
};
|
|
56
|
+
function assertRoot() {
|
|
57
|
+
if (compromised)
|
|
58
|
+
throw lost();
|
|
59
|
+
try {
|
|
60
|
+
if (!sameDirectory(fs.lstatSync(directory), original))
|
|
61
|
+
throw lost();
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
throw lost();
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
function assertOwned() {
|
|
68
|
+
assertRoot();
|
|
69
|
+
try {
|
|
70
|
+
const current = fs.lstatSync(lockPath);
|
|
71
|
+
if (!identity ||
|
|
72
|
+
!owner ||
|
|
73
|
+
!sameDirectory(current, identity) ||
|
|
74
|
+
(mtime !== undefined && current.mtimeMs !== mtime))
|
|
75
|
+
throw lost();
|
|
76
|
+
assertOwner(owner);
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
throw lost();
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
// lockSync's adapter invokes these synchronous functions for acquisition,
|
|
83
|
+
// every heartbeat and exit cleanup. Guarding only callback-style fs methods
|
|
84
|
+
// would leave its renewal and release paths unprotected.
|
|
85
|
+
const guardedFs = {
|
|
86
|
+
...fs,
|
|
87
|
+
mkdirSync(path) {
|
|
88
|
+
if (path !== lockPath)
|
|
89
|
+
return fs.mkdirSync(path);
|
|
90
|
+
assertRoot();
|
|
91
|
+
fs.mkdirSync(path, { mode: 0o700 });
|
|
92
|
+
identity = fs.lstatSync(path);
|
|
93
|
+
const name = `.owner-${randomUUID()}`;
|
|
94
|
+
const marker = fs.openSync(join(path, name), fs.constants.O_CREAT |
|
|
95
|
+
fs.constants.O_EXCL |
|
|
96
|
+
fs.constants.O_WRONLY |
|
|
97
|
+
fs.constants.O_NOFOLLOW, 0o600);
|
|
98
|
+
try {
|
|
99
|
+
owner = { name, identity: fs.fstatSync(marker) };
|
|
100
|
+
if (!ownerFile(owner.identity))
|
|
101
|
+
throw lost();
|
|
102
|
+
}
|
|
103
|
+
finally {
|
|
104
|
+
fs.closeSync(marker);
|
|
105
|
+
}
|
|
106
|
+
mtime = fs.lstatSync(path).mtimeMs;
|
|
107
|
+
assertOwned();
|
|
108
|
+
},
|
|
109
|
+
statSync(path) {
|
|
110
|
+
if (path !== lockPath)
|
|
111
|
+
return fs.statSync(path);
|
|
112
|
+
if (identity)
|
|
113
|
+
assertOwned();
|
|
114
|
+
else
|
|
115
|
+
assertRoot();
|
|
116
|
+
const current = fs.lstatSync(path);
|
|
117
|
+
if (!current.isDirectory() || current.isSymbolicLink())
|
|
118
|
+
throw lost();
|
|
119
|
+
// An existing stale lock may be removed only if it remains the exact
|
|
120
|
+
// directory whose age proper-lockfile just inspected.
|
|
121
|
+
if (!identity) {
|
|
122
|
+
candidate = current;
|
|
123
|
+
candidateOwner = readOwner();
|
|
124
|
+
}
|
|
125
|
+
return current;
|
|
126
|
+
},
|
|
127
|
+
utimesSync(path, atime, nextMtime) {
|
|
128
|
+
if (path !== lockPath)
|
|
129
|
+
return fs.utimesSync(path, atime, nextMtime);
|
|
130
|
+
assertOwned();
|
|
131
|
+
const descriptor = fs.openSync(path, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
|
|
132
|
+
try {
|
|
133
|
+
if (!sameDirectory(fs.fstatSync(descriptor), identity))
|
|
134
|
+
throw lost();
|
|
135
|
+
// Renew the captured inode, never a replacement installed between the
|
|
136
|
+
// path check and the write. Recheck the public path before accepting it.
|
|
137
|
+
fs.futimesSync(descriptor, atime, nextMtime);
|
|
138
|
+
mtime = fs.fstatSync(descriptor).mtimeMs;
|
|
139
|
+
assertOwned();
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
throw lost();
|
|
143
|
+
}
|
|
144
|
+
finally {
|
|
145
|
+
fs.closeSync(descriptor);
|
|
146
|
+
}
|
|
147
|
+
},
|
|
148
|
+
rmdirSync(path) {
|
|
149
|
+
if (path !== lockPath)
|
|
150
|
+
return fs.rmdirSync(path);
|
|
151
|
+
if (identity) {
|
|
152
|
+
assertOwned();
|
|
153
|
+
fs.unlinkSync(join(path, owner.name));
|
|
154
|
+
}
|
|
155
|
+
else {
|
|
156
|
+
assertRoot();
|
|
157
|
+
if (!candidate || !sameDirectory(fs.lstatSync(path), candidate))
|
|
158
|
+
throw lost();
|
|
159
|
+
if (candidateOwner) {
|
|
160
|
+
assertOwner(candidateOwner);
|
|
161
|
+
fs.unlinkSync(join(path, candidateOwner.name));
|
|
162
|
+
}
|
|
163
|
+
else if (fs.readdirSync(path).length)
|
|
164
|
+
throw lost();
|
|
165
|
+
}
|
|
166
|
+
// Only our own random marker (or the exact observed stale candidate's)
|
|
167
|
+
// was removed. If the pathname changes immediately before this syscall,
|
|
168
|
+
// an established new owner still has its different marker: ENOTEMPTY
|
|
169
|
+
// preserves it, and stale acquisition must fail rather than claim success.
|
|
170
|
+
fs.rmdirSync(path);
|
|
171
|
+
},
|
|
172
|
+
};
|
|
10
173
|
let unlock;
|
|
11
174
|
try {
|
|
12
175
|
unlock = lockSync(directory, {
|
|
176
|
+
realpath: false,
|
|
177
|
+
lockfilePath: lockPath,
|
|
13
178
|
stale: 30000,
|
|
14
179
|
update: 10000,
|
|
15
180
|
retries: 0,
|
|
181
|
+
fs: guardedFs,
|
|
16
182
|
onCompromised: () => {
|
|
17
183
|
compromised = true;
|
|
18
184
|
},
|
|
19
185
|
});
|
|
186
|
+
assertOwned();
|
|
20
187
|
}
|
|
21
|
-
catch {
|
|
22
|
-
throw new Error(
|
|
188
|
+
catch (error) {
|
|
189
|
+
throw new Error(compromised
|
|
190
|
+
? "Project controller lock is no longer held."
|
|
191
|
+
: "Another project controller owns this data directory; stop it before starting another.", { cause: error });
|
|
23
192
|
}
|
|
24
193
|
return {
|
|
25
194
|
assertHeld() {
|
|
26
195
|
if (released || compromised)
|
|
27
196
|
throw new Error("Project controller lock is no longer held.");
|
|
28
|
-
|
|
29
|
-
if (!current.isDirectory() ||
|
|
30
|
-
current.isSymbolicLink() ||
|
|
31
|
-
current.dev !== original.dev ||
|
|
32
|
-
current.ino !== original.ino)
|
|
33
|
-
throw new Error("Project control directory changed while the controller was running.");
|
|
197
|
+
assertOwned();
|
|
34
198
|
},
|
|
35
199
|
release() {
|
|
36
200
|
if (released)
|
|
37
201
|
return;
|
|
38
202
|
released = true;
|
|
39
|
-
|
|
40
|
-
|
|
203
|
+
try {
|
|
204
|
+
assertOwned();
|
|
205
|
+
}
|
|
206
|
+
catch {
|
|
207
|
+
return; // The lost owner may never delete another owner's replacement.
|
|
208
|
+
}
|
|
209
|
+
unlock();
|
|
41
210
|
},
|
|
42
211
|
};
|
|
43
212
|
}
|
|
@@ -1,13 +1,16 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { ProjectControlSession } from "./control-authority.js";
|
|
2
2
|
import { ProjectRegistry, type ProjectView } from "./registry.js";
|
|
3
3
|
import type { ProjectRuntimeConnection, ProjectRuntimeProvider } from "./types.js";
|
|
4
4
|
export interface ProjectManagerOptions {
|
|
5
5
|
registry: ProjectRegistry;
|
|
6
6
|
provider: ProjectRuntimeProvider;
|
|
7
7
|
publicOrigin(): string;
|
|
8
|
-
isSessionActive(session:
|
|
8
|
+
isSessionActive(session: ProjectControlSession): boolean;
|
|
9
9
|
revokeProject(id: string): Promise<void>;
|
|
10
|
+
revalidateSession?(session: ProjectControlSession): Promise<void>;
|
|
10
11
|
maxRunning?: number;
|
|
12
|
+
maxRunningPerOwner?: number;
|
|
13
|
+
maxProjectsPerOwner?: number;
|
|
11
14
|
/** Clock seam for Docker observations; authorization is never cached. */
|
|
12
15
|
now?: () => number;
|
|
13
16
|
}
|
|
@@ -17,24 +20,29 @@ export declare class ProjectManager {
|
|
|
17
20
|
private readonly starts;
|
|
18
21
|
private readonly stops;
|
|
19
22
|
private readonly targets;
|
|
23
|
+
private readonly stopRevisions;
|
|
20
24
|
private readonly statusChecks;
|
|
21
25
|
private closed;
|
|
22
26
|
private closing;
|
|
23
27
|
constructor(options: ProjectManagerOptions);
|
|
24
28
|
private authorize;
|
|
29
|
+
private ownedProject;
|
|
30
|
+
private authorizeContinuous;
|
|
25
31
|
reconcile(): Promise<void>;
|
|
26
|
-
list(session:
|
|
27
|
-
get(session:
|
|
28
|
-
create(session:
|
|
29
|
-
start(session:
|
|
30
|
-
stop(session:
|
|
32
|
+
list(session: ProjectControlSession): ProjectView[];
|
|
33
|
+
get(session: ProjectControlSession, id: string): ProjectView;
|
|
34
|
+
create(session: ProjectControlSession, name: unknown): ProjectView;
|
|
35
|
+
start(session: ProjectControlSession, id: string): Promise<ProjectView>;
|
|
36
|
+
stop(session: ProjectControlSession, id: string): Promise<ProjectView>;
|
|
31
37
|
private beginStop;
|
|
32
38
|
private stopProject;
|
|
33
|
-
resolveTarget(session:
|
|
39
|
+
resolveTarget(session: ProjectControlSession, id: string): Promise<ProjectRuntimeConnection>;
|
|
34
40
|
private observeRuntime;
|
|
35
41
|
/** Stop the affected runtime without relying on its already-revoked outer session. */
|
|
36
42
|
stopAfterRevocationFailure(id: string): Promise<void>;
|
|
37
43
|
/** Emergency fallback when an outer owner's affected projects are unavailable. */
|
|
38
44
|
stopAll(): Promise<void>;
|
|
45
|
+
/** Revocation fallback for one Account; never interrupts another owner's runtimes. */
|
|
46
|
+
stopOwner(ownerId: string): Promise<void>;
|
|
39
47
|
close(): Promise<void>;
|
|
40
48
|
}
|
|
@@ -5,6 +5,7 @@ export class ProjectManager {
|
|
|
5
5
|
starts = new Map();
|
|
6
6
|
stops = new Map();
|
|
7
7
|
targets = new Map();
|
|
8
|
+
stopRevisions = new Map();
|
|
8
9
|
statusChecks = new Map();
|
|
9
10
|
closed = false;
|
|
10
11
|
closing;
|
|
@@ -14,6 +15,12 @@ export class ProjectManager {
|
|
|
14
15
|
(options.maxRunning ?? 4) < 1 ||
|
|
15
16
|
(options.maxRunning ?? 4) > 32)
|
|
16
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}.`);
|
|
17
24
|
}
|
|
18
25
|
authorize(session) {
|
|
19
26
|
if (this.closed)
|
|
@@ -21,6 +28,16 @@ export class ProjectManager {
|
|
|
21
28
|
if (!this.options.isSessionActive(session))
|
|
22
29
|
throw new ProjectRegistryError(401, "登录已过期,请重新登录。");
|
|
23
30
|
}
|
|
31
|
+
ownedProject(session, id) {
|
|
32
|
+
return "kind" in session
|
|
33
|
+
? this.options.registry.getAccount(session.ownerId, id)
|
|
34
|
+
: this.options.registry.get(session.username, id);
|
|
35
|
+
}
|
|
36
|
+
async authorizeContinuous(session) {
|
|
37
|
+
this.authorize(session);
|
|
38
|
+
await this.options.revalidateSession?.(session);
|
|
39
|
+
this.authorize(session);
|
|
40
|
+
}
|
|
24
41
|
async reconcile() {
|
|
25
42
|
this.statusChecks.clear();
|
|
26
43
|
// Never adopt a previous controller's approvals or device credentials after a crash.
|
|
@@ -41,30 +58,52 @@ export class ProjectManager {
|
|
|
41
58
|
}
|
|
42
59
|
list(session) {
|
|
43
60
|
this.authorize(session);
|
|
44
|
-
return
|
|
61
|
+
return "kind" in session
|
|
62
|
+
? this.options.registry.listAccount(session.ownerId)
|
|
63
|
+
: this.options.registry.list(session.username);
|
|
45
64
|
}
|
|
46
65
|
get(session, id) {
|
|
47
66
|
this.authorize(session);
|
|
48
|
-
return projectView(this.
|
|
67
|
+
return projectView(this.ownedProject(session, id));
|
|
49
68
|
}
|
|
50
69
|
create(session, name) {
|
|
51
70
|
this.authorize(session);
|
|
52
|
-
|
|
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} 个项目。`);
|
|
76
|
+
return projectView("kind" in session
|
|
77
|
+
? this.options.registry.createAccount(session.ownerId, name)
|
|
78
|
+
: this.options.registry.create(session.username, name));
|
|
53
79
|
}
|
|
54
80
|
async start(session, id) {
|
|
55
81
|
this.authorize(session);
|
|
56
|
-
this.
|
|
82
|
+
this.ownedProject(session, id);
|
|
83
|
+
const revision = this.stopRevisions.get(id) ?? 0;
|
|
84
|
+
const authorizeStart = () => {
|
|
85
|
+
this.authorize(session);
|
|
86
|
+
if ((this.stopRevisions.get(id) ?? 0) !== revision)
|
|
87
|
+
throw new ProjectRegistryError(409, "项目启动已取消。");
|
|
88
|
+
};
|
|
89
|
+
if (this.options.revalidateSession)
|
|
90
|
+
await this.authorizeContinuous(session);
|
|
91
|
+
authorizeStart();
|
|
57
92
|
const stopping = this.stops.get(id);
|
|
58
93
|
if (stopping)
|
|
59
94
|
await stopping;
|
|
60
|
-
this.
|
|
95
|
+
if (this.options.revalidateSession)
|
|
96
|
+
await this.authorizeContinuous(session);
|
|
97
|
+
authorizeStart();
|
|
61
98
|
const existing = this.starts.get(id);
|
|
62
99
|
if (existing) {
|
|
63
100
|
const result = await existing;
|
|
64
|
-
this.
|
|
101
|
+
if (this.options.revalidateSession)
|
|
102
|
+
await this.authorizeContinuous(session);
|
|
103
|
+
authorizeStart();
|
|
65
104
|
return result;
|
|
66
105
|
}
|
|
67
|
-
const project = this.
|
|
106
|
+
const project = this.ownedProject(session, id);
|
|
68
107
|
if (project.status === "running") {
|
|
69
108
|
await this.resolveTarget(session, id);
|
|
70
109
|
return this.get(session, id);
|
|
@@ -76,6 +115,16 @@ export class ProjectManager {
|
|
|
76
115
|
item.generation > 0).length;
|
|
77
116
|
if (count >= (this.options.maxRunning ?? 4))
|
|
78
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
|
+
}
|
|
79
128
|
this.statusChecks.delete(id);
|
|
80
129
|
const record = this.options.registry.update(id, {
|
|
81
130
|
status: "starting",
|
|
@@ -84,14 +133,18 @@ export class ProjectManager {
|
|
|
84
133
|
const operation = (async () => {
|
|
85
134
|
try {
|
|
86
135
|
await this.options.revokeProject(id);
|
|
87
|
-
this.
|
|
88
|
-
|
|
136
|
+
if (this.options.revalidateSession)
|
|
137
|
+
await this.authorizeContinuous(session);
|
|
138
|
+
authorizeStart();
|
|
139
|
+
if (this.ownedProject(session, id).status !== "starting")
|
|
89
140
|
throw new ProjectRegistryError(409, "项目启动已取消。");
|
|
90
141
|
const target = await this.options.provider.ensure(record, {
|
|
91
142
|
publicOrigin: this.options.publicOrigin(),
|
|
92
143
|
});
|
|
93
|
-
this.
|
|
94
|
-
|
|
144
|
+
if (this.options.revalidateSession)
|
|
145
|
+
await this.authorizeContinuous(session);
|
|
146
|
+
authorizeStart();
|
|
147
|
+
const current = this.ownedProject(session, id);
|
|
95
148
|
if (current.status !== "starting" || current.generation !== record.generation)
|
|
96
149
|
throw new ProjectRegistryError(409, "项目启动已取消。");
|
|
97
150
|
if (target.generation !== record.generation)
|
|
@@ -109,7 +162,7 @@ export class ProjectManager {
|
|
|
109
162
|
catch {
|
|
110
163
|
stopped = false;
|
|
111
164
|
}
|
|
112
|
-
const current = this.
|
|
165
|
+
const current = this.ownedProject(session, id);
|
|
113
166
|
if (current.generation === record.generation && current.status === "starting") {
|
|
114
167
|
this.options.registry.update(id, {
|
|
115
168
|
status: stopped ? "stopped" : "error",
|
|
@@ -129,8 +182,13 @@ export class ProjectManager {
|
|
|
129
182
|
}
|
|
130
183
|
async stop(session, id) {
|
|
131
184
|
this.authorize(session);
|
|
132
|
-
this.
|
|
185
|
+
this.ownedProject(session, id);
|
|
186
|
+
this.stopRevisions.set(id, (this.stopRevisions.get(id) ?? 0) + 1);
|
|
187
|
+
if (this.options.revalidateSession)
|
|
188
|
+
await this.authorizeContinuous(session);
|
|
133
189
|
const result = await this.beginStop(id);
|
|
190
|
+
if (this.options.revalidateSession)
|
|
191
|
+
await this.authorizeContinuous(session);
|
|
134
192
|
this.authorize(session);
|
|
135
193
|
return result;
|
|
136
194
|
}
|
|
@@ -168,15 +226,19 @@ export class ProjectManager {
|
|
|
168
226
|
}
|
|
169
227
|
}
|
|
170
228
|
async resolveTarget(session, id) {
|
|
229
|
+
if (this.options.revalidateSession)
|
|
230
|
+
await this.authorizeContinuous(session);
|
|
171
231
|
this.authorize(session);
|
|
172
|
-
const record = this.
|
|
232
|
+
const record = this.ownedProject(session, id);
|
|
173
233
|
const target = this.targets.get(id);
|
|
174
234
|
if (record.status !== "running" || !target || target.generation !== record.generation)
|
|
175
235
|
throw new ProjectRegistryError(409, "项目尚未运行,请先启动项目。");
|
|
176
236
|
// The provider checks labels and the loopback binding, not just a remembered port.
|
|
177
237
|
const state = await this.observeRuntime(record, target);
|
|
238
|
+
if (this.options.revalidateSession)
|
|
239
|
+
await this.authorizeContinuous(session);
|
|
178
240
|
this.authorize(session);
|
|
179
|
-
const current = this.
|
|
241
|
+
const current = this.ownedProject(session, id);
|
|
180
242
|
if (current.status !== "running" || current.generation !== target.generation)
|
|
181
243
|
throw new ProjectRegistryError(409, "项目已停止或重新启动,请重新进入。");
|
|
182
244
|
if (state.state !== "running" || state.url !== target.url) {
|
|
@@ -232,6 +294,18 @@ export class ProjectManager {
|
|
|
232
294
|
if (results.some((result) => result.status === "rejected" || result.value.status !== "stopped"))
|
|
233
295
|
throw new ProjectRegistryError(503, "部分项目尚未确认停止,请检查 Docker。");
|
|
234
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
|
+
}
|
|
235
309
|
close() {
|
|
236
310
|
if (this.closing)
|
|
237
311
|
return this.closing;
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { type IncomingMessage, type ServerResponse } from "node:http";
|
|
2
2
|
import { type Duplex } from "node:stream";
|
|
3
3
|
import { type HubAuth } from "../hub/auth-http.js";
|
|
4
|
-
import type {
|
|
4
|
+
import type { ProjectControlAuthority, ProjectControlSession } from "./control-authority.js";
|
|
5
5
|
import type { ProjectRuntimeConnection } from "./types.js";
|
|
6
6
|
export type RuntimeTarget = ProjectRuntimeConnection;
|
|
7
7
|
export interface ProjectRuntimeProxyOptions {
|
|
8
|
-
auth: HubAuth;
|
|
9
|
-
resolveTarget(projectId: string, session:
|
|
8
|
+
auth: HubAuth | ProjectControlAuthority;
|
|
9
|
+
resolveTarget(projectId: string, session: ProjectControlSession): Promise<RuntimeTarget>;
|
|
10
10
|
publicOrigin: () => string;
|
|
11
11
|
/** Stop this runtime if its private logout cannot acknowledge revocation.
|
|
12
12
|
* Dispatched after retirement settles; may call back into revokeProject. */
|
|
@@ -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})(/.*)$`);
|
|
@@ -86,6 +87,9 @@ export function createProjectRuntimeProxy(options) {
|
|
|
86
87
|
}
|
|
87
88
|
async function validate(lease) {
|
|
88
89
|
const session = assertLease(lease);
|
|
90
|
+
if ("revalidate" in options.auth)
|
|
91
|
+
await options.auth.revalidate(session);
|
|
92
|
+
assertLease(lease);
|
|
89
93
|
const current = checkedTarget(await options.resolveTarget(lease.projectId, session));
|
|
90
94
|
assertLease(lease);
|
|
91
95
|
if (!sameTarget(current, lease.target)) {
|
|
@@ -212,7 +216,7 @@ export function createProjectRuntimeProxy(options) {
|
|
|
212
216
|
// An opaque iframe has no cookie and Origin:null. Only its already issued
|
|
213
217
|
// random capability takes this path; another signed-in owner cannot borrow it.
|
|
214
218
|
if (req.headers.authorization !== undefined ||
|
|
215
|
-
req.headers.cookie?.includes(`${HUB_SESSION_COOKIE}=`)) {
|
|
219
|
+
req.headers.cookie?.includes(`${"cookieName" in options.auth ? options.auth.cookieName : HUB_SESSION_COOKIE}=`)) {
|
|
216
220
|
const session = await options.auth.authenticate(req);
|
|
217
221
|
if (!session || session.id !== grant.lease.sessionId)
|
|
218
222
|
throw new ProxyError(403, "Panel asset belongs to another session");
|
|
@@ -750,13 +754,19 @@ function httpError(res, error) {
|
|
|
750
754
|
res.destroy();
|
|
751
755
|
return;
|
|
752
756
|
}
|
|
753
|
-
const status = error instanceof ProxyError ? error.status : 503;
|
|
757
|
+
const status = error instanceof ProxyError || error instanceof ProjectRegistryError ? error.status : 503;
|
|
754
758
|
res.writeHead(status, { "Content-Type": "application/json", "Cache-Control": "no-store" });
|
|
755
759
|
res.end(JSON.stringify({
|
|
756
|
-
error: error instanceof ProxyError
|
|
760
|
+
error: error instanceof ProxyError
|
|
761
|
+
? error.message
|
|
762
|
+
: status === 401
|
|
763
|
+
? "Authentication required"
|
|
764
|
+
: status === 404
|
|
765
|
+
? "Project endpoint not found"
|
|
766
|
+
: "Project runtime unavailable",
|
|
757
767
|
}));
|
|
758
768
|
}
|
|
759
769
|
function upgradeError(socket, error) {
|
|
760
|
-
const status = error instanceof ProxyError ? error.status : 503;
|
|
761
|
-
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());
|
|
762
772
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { renameSync } from "node:fs";
|
|
1
|
+
import { fsyncSync, renameSync } from "node:fs";
|
|
2
2
|
import type { ProjectRuntimeRecord } from "./types.js";
|
|
3
|
+
import { type CloudAccountBinding, type CloudMultiAccountConfiguration } from "./account-binding.js";
|
|
3
4
|
export type ProjectStatus = "stopped" | "starting" | "running" | "stopping" | "error";
|
|
4
5
|
export interface StoredProject extends ProjectRuntimeRecord {
|
|
5
6
|
name: string;
|
|
@@ -32,14 +33,26 @@ export declare class ProjectRegistry {
|
|
|
32
33
|
private readonly controllerLock;
|
|
33
34
|
private diskDigest;
|
|
34
35
|
private markerEstablished;
|
|
36
|
+
private storageFailed;
|
|
35
37
|
constructor(dataDir: string, now?: () => number, io?: {
|
|
36
38
|
rename?: typeof renameSync;
|
|
39
|
+
fsync?: typeof fsyncSync;
|
|
40
|
+
offlineAccountAdoption?: boolean;
|
|
41
|
+
/** Explicit startup policy; never inferred from the first authenticated user. */
|
|
42
|
+
multiAccountOrigin?: string;
|
|
37
43
|
});
|
|
38
44
|
/** Release the single-controller lease on shutdown or startup failure. */
|
|
39
45
|
close(): void;
|
|
40
46
|
/** Offline restore only: new Docker namespace, same project credentials for restored data volumes. */
|
|
41
47
|
renewInstallationForRestore(): void;
|
|
42
48
|
get installationId(): string;
|
|
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;
|
|
54
|
+
/** Offline migration only; the caller must durably preserve source evidence first. */
|
|
55
|
+
bindAccount(binding: CloudAccountBinding): void;
|
|
43
56
|
private validate;
|
|
44
57
|
private assertStorage;
|
|
45
58
|
private write;
|
|
@@ -47,6 +60,13 @@ export declare class ProjectRegistry {
|
|
|
47
60
|
list(username: string): ProjectView[];
|
|
48
61
|
get(username: string, id: string): StoredProject;
|
|
49
62
|
create(username: string, name: unknown): StoredProject;
|
|
63
|
+
listAccount(ownerId: string): ProjectView[];
|
|
64
|
+
getAccount(ownerId: string, id: string): StoredProject;
|
|
65
|
+
createAccount(ownerId: string, name: unknown): StoredProject;
|
|
66
|
+
private requireLegacy;
|
|
67
|
+
private requireAccountOwner;
|
|
68
|
+
private createForOwner;
|
|
69
|
+
private createForAccountOwner;
|
|
50
70
|
update(id: string, patch: {
|
|
51
71
|
status: ProjectStatus;
|
|
52
72
|
generation?: number;
|