@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,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,53 @@
1
+ #!/usr/bin/env node
2
+ import { adoptCloudAccount, inspectCloudAccountAdoption, } from "../project-runtime/account-adoption.js";
3
+ const help = `Usage: code-shell-cloud-account --inspect --data-dir <canonical path>
4
+ code-shell-cloud-account --adopt --data-dir <canonical path>
5
+ --evidence-dir <new private directory outside data-dir>
6
+ --account-origin <exact Account HTTPS origin> --account-id <UUID>
7
+ --installation-id <reviewed UUID> --owner-id <reviewed UUID>
8
+ --legacy-username <reviewed sole operator>
9
+
10
+ Stop the Cloud controller and all projects first. Adoption durably preserves
11
+ original authority files and SHA receipts before retiring legacy login. Evidence
12
+ contains private password hashes. Make a full installation/volume backup too.
13
+ Retry a failed adoption with exactly the same source, evidence and identities.
14
+ This tool never contacts Account, accepts a password/token, or runs Docker.
15
+ The bound Account must authenticate through the configured Cloud at first use.`;
16
+ try {
17
+ const args = process.argv.slice(2);
18
+ if (args.length === 1 && ["--help", "-h"].includes(args[0]))
19
+ console.log(help);
20
+ else {
21
+ const mode = args.shift();
22
+ if (mode !== "--inspect" && mode !== "--adopt")
23
+ throw new Error("Choose --inspect or --adopt.");
24
+ const names = {
25
+ "--data-dir": "dataDir",
26
+ "--evidence-dir": "evidenceDirectory",
27
+ "--account-origin": "accountOrigin",
28
+ "--account-id": "accountId",
29
+ "--installation-id": "installationId",
30
+ "--owner-id": "ownerId",
31
+ "--legacy-username": "legacyUsername",
32
+ };
33
+ const options = {};
34
+ while (args.length) {
35
+ const name = args.shift(), key = names[name], value = args.shift();
36
+ if (!key || !value || value.startsWith("--") || key in options)
37
+ throw new Error("Unknown, duplicate or incomplete option.");
38
+ options[key] = value;
39
+ }
40
+ if (!options.dataDir ||
41
+ (mode === "--inspect"
42
+ ? Object.keys(options).length !== 1
43
+ : Object.keys(options).length !== Object.keys(names).length))
44
+ throw new Error("Supply every reviewed identity; use --help for usage.");
45
+ console.log(JSON.stringify(mode === "--inspect"
46
+ ? inspectCloudAccountAdoption(options.dataDir)
47
+ : adoptCloudAccount(options), null, 2));
48
+ }
49
+ }
50
+ catch (error) {
51
+ console.error(error instanceof Error ? error.message : "Cloud account adoption failed.");
52
+ process.exitCode = 1;
53
+ }
@@ -1,6 +1,9 @@
1
1
  /** Ready-made headless HTTP/WebSocket host and CLI composition helpers. */
2
2
  export * from "./serve/headless-server.js";
3
3
  export { startProjectControlServer, type ProjectControlServerOptions, } from "./project-runtime/control-server.js";
4
+ export { CLOUD_MANAGEMENT_AUTHORITY_VERSION } from "./project-runtime/cloud-account-auth.js";
5
+ export { adoptCloudAccount, inspectCloudAccountAdoption, type CloudAccountAdoptionOptions, } from "./project-runtime/account-adoption.js";
6
+ export type { CloudAccountAuthority, CloudAccountPrincipal, CloudManagementSession, } from "./project-runtime/control-authority.js";
4
7
  export { parseServeArgs, resolveWebAppRoot, resolveWorkerCapabilityModules, resolveWorkerEntry, runServeCli, } from "./serve/cli.js";
5
8
  export { backupProjectInstallation, restoreProjectInstallation, type ProjectBackupOptions, type ProjectRestoreOptions, } from "./project-runtime/backup.js";
6
9
  export { readProjectSeccompProfile, type ProjectSeccompProfile, } from "./project-runtime/seccomp.js";
@@ -1,6 +1,8 @@
1
1
  /** Ready-made headless HTTP/WebSocket host and CLI composition helpers. */
2
2
  export * from "./serve/headless-server.js";
3
3
  export { startProjectControlServer, } from "./project-runtime/control-server.js";
4
+ export { CLOUD_MANAGEMENT_AUTHORITY_VERSION } from "./project-runtime/cloud-account-auth.js";
5
+ export { adoptCloudAccount, inspectCloudAccountAdoption, } from "./project-runtime/account-adoption.js";
4
6
  export { parseServeArgs, resolveWebAppRoot, resolveWorkerCapabilityModules, resolveWorkerEntry, runServeCli, } from "./serve/cli.js";
5
7
  export { backupProjectInstallation, restoreProjectInstallation, } from "./project-runtime/backup.js";
6
8
  export { readProjectSeccompProfile, } from "./project-runtime/seccomp.js";
@@ -396,6 +396,12 @@ export function createPanelManagement(options) {
396
396
  source,
397
397
  expectedRevision: current?.revision ?? null,
398
398
  compatibility: compatibility(preview),
399
+ ...(current
400
+ ? {
401
+ installedVersion: current.app.version,
402
+ installedPermissions: [...current.app.permissions],
403
+ }
404
+ : {}),
399
405
  };
400
406
  reviews.set(reviewToken, {
401
407
  owner: context.ownerId,
@@ -458,7 +464,12 @@ export function createPanelManagement(options) {
458
464
  preview: publicPreview,
459
465
  expectedRevision: currentRevision,
460
466
  compatibility: compatibility(preview),
461
- ...(current ? { installedVersion: current.app.version } : {}),
467
+ ...(current
468
+ ? {
469
+ installedVersion: current.app.version,
470
+ installedPermissions: [...current.app.permissions],
471
+ }
472
+ : {}),
462
473
  };
463
474
  reviews.set(value.reviewToken, {
464
475
  owner: context.ownerId,
@@ -57,11 +57,13 @@ export interface PanelReview {
57
57
  source: PanelGitSource;
58
58
  expectedRevision: string | null;
59
59
  compatibility: PanelCompatibility;
60
- }
61
- /** Trusted native hosts may review local sources without exposing paths to Web routes. */
62
- export interface PanelProjectReview extends Omit<PanelReview, "source"> {
60
+ /** The installed package selected for this project, when its manifest is available. */
63
61
  installedVersion?: string;
62
+ /** Reviewed permissions of that same installed package; an empty array is a known baseline. */
63
+ installedPermissions?: PanelAppPreview["permissions"];
64
64
  }
65
+ /** Trusted native hosts may review local sources without exposing paths to Web routes. */
66
+ export type PanelProjectReview = Omit<PanelReview, "source">;
65
67
  export interface PanelOperationContext {
66
68
  ownerId: string;
67
69
  authorize: () => boolean | Promise<boolean>;
@@ -0,0 +1,34 @@
1
+ import { fsyncSync } from "node:fs";
2
+ import { type CloudAccountBinding } from "./account-binding.js";
3
+ export interface CloudAccountAdoptionOptions {
4
+ dataDir: string;
5
+ /** A new private directory outside the source. Authority evidence contains password hashes. */
6
+ evidenceDirectory: string;
7
+ accountOrigin: string;
8
+ accountId: string;
9
+ /** Both identities must be explicitly reviewed from the existing offline installation. */
10
+ installationId: string;
11
+ ownerId: string;
12
+ legacyUsername: string;
13
+ /** Fault seam; no user request can configure these hooks. */
14
+ io?: {
15
+ fsync?: typeof fsyncSync;
16
+ };
17
+ }
18
+ /** Read-only operator review; private runtime passwords and Hub hashes are excluded. */
19
+ export declare function inspectCloudAccountAdoption(dataDir: string): {
20
+ installationId: string;
21
+ owners: any;
22
+ accountBinding: CloudAccountBinding | null;
23
+ projects: {
24
+ id: string;
25
+ status: import("./registry.js").ProjectStatus;
26
+ generation: number;
27
+ }[];
28
+ pending: boolean;
29
+ };
30
+ /** Offline, explicit adoption. Never rolls back or guesses authority from a username. */
31
+ export declare function adoptCloudAccount(options: CloudAccountAdoptionOptions): {
32
+ binding: CloudAccountBinding;
33
+ evidenceDirectory: string;
34
+ };
@@ -0,0 +1,280 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { constants, closeSync, existsSync, fstatSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, realpathSync, unlinkSync, writeFileSync, } from "node:fs";
3
+ import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
4
+ import { HubAuthStore } from "../hub/auth-store.js";
5
+ import { CLOUD_ADOPTION_PENDING, isCloudAccountBinding, isCloudAccountOrigin, } from "./account-binding.js";
6
+ import { ProjectRegistry } from "./registry.js";
7
+ const sha = (bytes) => createHash("sha256").update(bytes).digest("hex");
8
+ const UUID = /^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/;
9
+ const FORMAT = "codeshell.cloud-account-adoption";
10
+ const SOURCES = [
11
+ "project-control/registry.json",
12
+ "project-control/initialized",
13
+ "hub/auth.json",
14
+ ];
15
+ function privateBytes(file, maximum = 1024 * 1024) {
16
+ const before = lstatSync(file);
17
+ if (!before.isFile() ||
18
+ before.isSymbolicLink() ||
19
+ before.nlink !== 1 ||
20
+ before.mode & 0o077 ||
21
+ before.size > maximum)
22
+ throw new Error("Adoption evidence must be a bounded private regular file.");
23
+ const fd = openSync(file, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
24
+ try {
25
+ const after = fstatSync(fd);
26
+ if (after.dev !== before.dev ||
27
+ after.ino !== before.ino ||
28
+ after.nlink !== 1 ||
29
+ after.size !== before.size)
30
+ throw new Error("Adoption source changed while opening.");
31
+ const bytes = readFileSync(fd);
32
+ if (bytes.length !== before.size || bytes.length > maximum)
33
+ throw new Error("Adoption source changed while reading.");
34
+ return bytes;
35
+ }
36
+ finally {
37
+ closeSync(fd);
38
+ }
39
+ }
40
+ function ordinaryDirectory(path) {
41
+ const stat = lstatSync(path);
42
+ if (!stat.isDirectory() || stat.isSymbolicLink() || stat.mode & 0o077)
43
+ throw new Error("Adoption requires private ordinary directories.");
44
+ return stat;
45
+ }
46
+ /** Read-only operator review; private runtime passwords and Hub hashes are excluded. */
47
+ export function inspectCloudAccountAdoption(dataDir) {
48
+ const source = realpathSync(dataDir);
49
+ if (source !== resolve(dataDir))
50
+ throw new Error("Adoption source must be canonical.");
51
+ ordinaryDirectory(source);
52
+ for (const name of SOURCES)
53
+ privateBytes(join(source, name));
54
+ const registry = new ProjectRegistry(source, Date.now, { offlineAccountAdoption: true });
55
+ try {
56
+ const data = JSON.parse(privateBytes(join(source, SOURCES[0])).toString("utf8"));
57
+ return {
58
+ installationId: registry.installationId,
59
+ owners: data.owners,
60
+ accountBinding: registry.accountBinding ?? null,
61
+ projects: registry.all().map(({ id, status, generation }) => ({ id, status, generation })),
62
+ pending: existsSync(join(source, "project-control", CLOUD_ADOPTION_PENDING)),
63
+ };
64
+ }
65
+ finally {
66
+ registry.close();
67
+ }
68
+ }
69
+ /** Offline, explicit adoption. Never rolls back or guesses authority from a username. */
70
+ export function adoptCloudAccount(options) {
71
+ if (!isCloudAccountOrigin(options.accountOrigin) ||
72
+ !UUID.test(options.accountId) ||
73
+ !UUID.test(options.ownerId) ||
74
+ !UUID.test(options.installationId))
75
+ throw new Error("Explicit Cloud/Account UUIDs and exact Account origin are required.");
76
+ const source = realpathSync(options.dataDir);
77
+ if (source !== resolve(options.dataDir))
78
+ throw new Error("Adoption source must be canonical.");
79
+ const sourceStat = ordinaryDirectory(source);
80
+ const control = join(source, "project-control");
81
+ const controlStat = ordinaryDirectory(control);
82
+ ordinaryDirectory(join(source, "hub"));
83
+ const destination = resolve(options.evidenceDirectory);
84
+ const parent = realpathSync(dirname(destination));
85
+ ordinaryDirectory(parent);
86
+ if (parent !== dirname(destination))
87
+ throw new Error("Evidence destination must have a canonical parent.");
88
+ const rel = relative(source, destination);
89
+ if (!rel || (!isAbsolute(rel) && rel !== ".." && !rel.startsWith(`..${sep}`)))
90
+ throw new Error("Preserve evidence outside the installation.");
91
+ // Never initialize a missing/wrong installation while performing adoption.
92
+ for (const name of SOURCES)
93
+ privateBytes(join(source, name));
94
+ const flush = options.io?.fsync ?? fsyncSync;
95
+ const sync = (path) => {
96
+ const fd = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
97
+ try {
98
+ flush(fd);
99
+ }
100
+ finally {
101
+ closeSync(fd);
102
+ }
103
+ };
104
+ const save = (file, bytes) => {
105
+ if (existsSync(file)) {
106
+ if (!privateBytes(file).equals(bytes))
107
+ throw new Error("Adoption evidence changed; preserve it for diagnosis.");
108
+ }
109
+ else {
110
+ const fd = openSync(file, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW ?? 0), 0o600);
111
+ try {
112
+ writeFileSync(fd, bytes);
113
+ flush(fd);
114
+ }
115
+ finally {
116
+ closeSync(fd);
117
+ }
118
+ }
119
+ sync(file);
120
+ sync(dirname(file));
121
+ };
122
+ const registry = new ProjectRegistry(source, Date.now, {
123
+ fsync: flush,
124
+ offlineAccountAdoption: true,
125
+ });
126
+ try {
127
+ if (registry.installationId !== options.installationId ||
128
+ registry.all().some((project) => project.status !== "stopped"))
129
+ throw new Error("Adoption requires the reviewed installation with all projects stopped.");
130
+ if (!existsSync(destination)) {
131
+ mkdirSync(destination, { mode: 0o700 });
132
+ sync(parent);
133
+ }
134
+ ordinaryDirectory(destination);
135
+ const receiptPath = join(destination, "receipt.json");
136
+ let receipt;
137
+ if (existsSync(receiptPath)) {
138
+ receipt = JSON.parse(privateBytes(receiptPath).toString("utf8"));
139
+ if (receipt.format !== FORMAT ||
140
+ receipt.version !== 1 ||
141
+ !isCloudAccountBinding(receipt.binding) ||
142
+ receipt.source !== source ||
143
+ receipt.sourceDev !== sourceStat.dev ||
144
+ receipt.sourceIno !== sourceStat.ino ||
145
+ receipt.controllerDev !== controlStat.dev ||
146
+ receipt.controllerIno !== controlStat.ino ||
147
+ receipt.installationId !== options.installationId ||
148
+ receipt.binding.accountOrigin !== options.accountOrigin ||
149
+ receipt.binding.accountId !== options.accountId ||
150
+ receipt.binding.ownerId !== options.ownerId)
151
+ throw new Error("Adoption receipt does not match this explicit source and destination.");
152
+ }
153
+ else {
154
+ if (registry.accountBinding)
155
+ throw new Error("Bound Cloud requires its original adoption receipt.");
156
+ const originalRegistry = privateBytes(join(source, SOURCES[0]));
157
+ const data = JSON.parse(originalRegistry.toString("utf8"));
158
+ const auth = privateBytes(join(source, SOURCES[2]));
159
+ const authData = JSON.parse(auth.toString("utf8"));
160
+ const hub = new HubAuthStore({ dataDir: source });
161
+ if (!hub.isInitialized() ||
162
+ data.version !== 1 ||
163
+ data.owners.length !== 1 ||
164
+ data.owners[0].id !== options.ownerId ||
165
+ data.owners[0].username !== options.legacyUsername ||
166
+ authData.account?.username !== options.legacyUsername)
167
+ throw new Error("Explicit legacy owner must match the sole initialized operator.");
168
+ const binding = {
169
+ version: 1,
170
+ accountOrigin: options.accountOrigin,
171
+ accountId: options.accountId,
172
+ ownerId: options.ownerId,
173
+ managementEpoch: randomUUID(),
174
+ createdAt: Date.now(),
175
+ };
176
+ const bound = { ...data, version: 2, accountBinding: binding };
177
+ const revoked = { ...authData, sessions: [] };
178
+ receipt = {
179
+ format: FORMAT,
180
+ version: 1,
181
+ source,
182
+ sourceDev: sourceStat.dev,
183
+ sourceIno: sourceStat.ino,
184
+ controllerDev: controlStat.dev,
185
+ controllerIno: controlStat.ino,
186
+ installationId: options.installationId,
187
+ binding,
188
+ originals: {},
189
+ registryAfter: sha(Buffer.from(JSON.stringify(bound, null, 2) + "\n")),
190
+ authAfter: sha(Buffer.from(JSON.stringify(revoked, null, 2))),
191
+ };
192
+ for (const name of SOURCES) {
193
+ const bytes = privateBytes(join(source, name));
194
+ receipt.originals[name] = sha(bytes);
195
+ save(join(destination, name.replaceAll("/", "--")), bytes);
196
+ }
197
+ // Original evidence is fully flushed before publishing any adoption barrier.
198
+ save(receiptPath, Buffer.from(JSON.stringify(receipt, null, 2) + "\n"));
199
+ }
200
+ for (const name of SOURCES) {
201
+ const original = privateBytes(join(destination, name.replaceAll("/", "--")));
202
+ if (sha(original) !== receipt.originals[name])
203
+ throw new Error("Adoption evidence checksum mismatch.");
204
+ }
205
+ const originalData = JSON.parse(privateBytes(join(destination, SOURCES[0].replaceAll("/", "--"))).toString("utf8"));
206
+ const originalAuth = JSON.parse(privateBytes(join(destination, SOURCES[2].replaceAll("/", "--"))).toString("utf8"));
207
+ if (originalData.version !== 1 ||
208
+ originalData.installationId !== options.installationId ||
209
+ originalData.owners?.length !== 1 ||
210
+ originalData.owners[0].id !== options.ownerId ||
211
+ originalData.owners[0].username !== options.legacyUsername ||
212
+ originalAuth.account?.username !== options.legacyUsername ||
213
+ receipt.registryAfter !==
214
+ sha(Buffer.from(JSON.stringify({ ...originalData, version: 2, accountBinding: receipt.binding }, null, 2) + "\n")) ||
215
+ receipt.authAfter !==
216
+ sha(Buffer.from(JSON.stringify({ ...originalAuth, sessions: [] }, null, 2))))
217
+ throw new Error("Adoption receipt does not preserve the explicitly reviewed legacy authority.");
218
+ sync(receiptPath);
219
+ sync(destination);
220
+ const assertSource = () => {
221
+ const current = ordinaryDirectory(source), controller = ordinaryDirectory(control);
222
+ if (current.dev !== receipt.sourceDev ||
223
+ current.ino !== receipt.sourceIno ||
224
+ controller.dev !== receipt.controllerDev ||
225
+ controller.ino !== receipt.controllerIno)
226
+ throw new Error("Adoption source directory changed.");
227
+ for (const name of SOURCES) {
228
+ const value = sha(privateBytes(join(source, name)));
229
+ const allowed = [
230
+ receipt.originals[name],
231
+ name === SOURCES[0]
232
+ ? receipt.registryAfter
233
+ : name === SOURCES[2]
234
+ ? receipt.authAfter
235
+ : undefined,
236
+ ];
237
+ if (!allowed.includes(value))
238
+ throw new Error("Adoption source changed; preserve evidence and diagnose before retrying.");
239
+ }
240
+ };
241
+ assertSource();
242
+ const pending = join(control, CLOUD_ADOPTION_PENDING);
243
+ const marker = Buffer.from(JSON.stringify({
244
+ format: FORMAT,
245
+ version: 1,
246
+ evidenceDirectory: destination,
247
+ receipt: sha(privateBytes(receiptPath)),
248
+ }) + "\n");
249
+ save(pending, marker);
250
+ assertSource();
251
+ if (!registry.accountBinding)
252
+ registry.bindAccount(receipt.binding);
253
+ if (JSON.stringify(registry.accountBinding) !== JSON.stringify(receipt.binding) ||
254
+ sha(privateBytes(join(source, SOURCES[0]))) !== receipt.registryAfter)
255
+ throw new Error("Cloud adoption binding changed.");
256
+ sync(join(source, SOURCES[0]));
257
+ sync(control);
258
+ assertSource();
259
+ new HubAuthStore({ dataDir: source }).revokeAllSessions();
260
+ sync(join(source, SOURCES[2]));
261
+ sync(join(source, "hub"));
262
+ assertSource();
263
+ save(join(destination, "completed.json"), Buffer.from(JSON.stringify({
264
+ format: FORMAT,
265
+ receipt: sha(privateBytes(receiptPath)),
266
+ registry: receipt.registryAfter,
267
+ auth: receipt.authAfter,
268
+ }) + "\n"));
269
+ if (existsSync(pending)) {
270
+ if (!privateBytes(pending).equals(marker))
271
+ throw new Error("Adoption pending marker changed.");
272
+ unlinkSync(pending);
273
+ sync(control);
274
+ }
275
+ return { binding: { ...receipt.binding }, evidenceDirectory: destination };
276
+ }
277
+ finally {
278
+ registry.close();
279
+ }
280
+ }
@@ -0,0 +1,12 @@
1
+ /** Explicit single-operator adoption; account display names never confer ownership. */
2
+ export interface CloudAccountBinding {
3
+ version: 1;
4
+ accountOrigin: string;
5
+ accountId: string;
6
+ ownerId: string;
7
+ managementEpoch: string;
8
+ createdAt: number;
9
+ }
10
+ export declare const CLOUD_ADOPTION_PENDING = "account-adoption-pending.json";
11
+ export declare function isCloudAccountOrigin(value: unknown): value is string;
12
+ export declare function isCloudAccountBinding(value: unknown): value is CloudAccountBinding;
@@ -0,0 +1,33 @@
1
+ export const CLOUD_ADOPTION_PENDING = "account-adoption-pending.json";
2
+ const UUID = /^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/;
3
+ export function isCloudAccountOrigin(value) {
4
+ if (typeof value !== "string" || value.length > 2048)
5
+ return false;
6
+ try {
7
+ const url = new URL(value);
8
+ return (url.origin === value &&
9
+ !url.username &&
10
+ !url.password &&
11
+ (url.protocol === "https:" ||
12
+ (url.protocol === "http:" && ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname))));
13
+ }
14
+ catch {
15
+ return false;
16
+ }
17
+ }
18
+ export function isCloudAccountBinding(value) {
19
+ if (!value || typeof value !== "object" || Array.isArray(value))
20
+ return false;
21
+ const row = value;
22
+ return (Object.keys(row).sort().join() ===
23
+ ["version", "accountOrigin", "accountId", "ownerId", "managementEpoch", "createdAt"]
24
+ .sort()
25
+ .join() &&
26
+ row.version === 1 &&
27
+ isCloudAccountOrigin(row.accountOrigin) &&
28
+ UUID.test(row.accountId) &&
29
+ UUID.test(row.ownerId) &&
30
+ UUID.test(row.managementEpoch) &&
31
+ Number.isSafeInteger(row.createdAt) &&
32
+ row.createdAt >= 0);
33
+ }
@@ -1,3 +1,5 @@
1
+ import { fsyncSync } from "node:fs";
2
+ import { rm } from "node:fs/promises";
1
3
  export interface ProjectBackupOptions {
2
4
  dataDir: string;
3
5
  destination: string;
@@ -8,6 +10,11 @@ export interface ProjectRestoreOptions {
8
10
  source: string;
9
11
  destination: string;
10
12
  helperImage: string;
13
+ /** Fault injection for offline restore durability tests. */
14
+ io?: {
15
+ fsync?: typeof fsyncSync;
16
+ removeMarker?: typeof rm;
17
+ };
11
18
  }
12
19
  /** Offline, owner-only backup. It never stops projects or modifies their volumes. */
13
20
  export declare function backupProjectInstallation(options: ProjectBackupOptions): Promise<{
@@ -1,6 +1,6 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
2
  import { execFile, spawn } from "node:child_process";
3
- import { constants, createWriteStream } from "node:fs";
3
+ import { closeSync, constants, createWriteStream, fsyncSync, fstatSync, lstatSync, openSync, writeFileSync, } from "node:fs";
4
4
  import { lstat, mkdir, open, readdir, realpath, rm, writeFile } from "node:fs/promises";
5
5
  import { dirname, join, relative, resolve, sep } from "node:path";
6
6
  import { pipeline } from "node:stream/promises";
@@ -381,6 +381,7 @@ function validateManifest(value) {
381
381
  path.split("/").some((part) => !part || part === "." || part === "..") ||
382
382
  /[\0\r\n]/.test(path) ||
383
383
  path === "restore-in-progress" ||
384
+ ["project-runtime-secrets", "administrator-setup.txt", "environment.json"].some((obsolete) => path.startsWith(obsolete + "/")) ||
384
385
  path.startsWith("project-control.lock/") ||
385
386
  path === "project-control.lock" ||
386
387
  !entry ||
@@ -425,21 +426,48 @@ export async function restoreProjectInstallation(options) {
425
426
  same(digest(await controlBytes(join(source, "control"), path)), expected);
426
427
  await docker(["image", "inspect", options.helperImage]);
427
428
  const destination = await exclusiveDestination(source, resolve(options.destination));
429
+ const marker = join(destination, "restore-in-progress");
430
+ const directories = new Set([destination]);
431
+ const sync = (path) => {
432
+ const before = lstatSync(path);
433
+ if (before.isSymbolicLink() || (!before.isFile() && !before.isDirectory()))
434
+ throw new Error("Restore durability requires ordinary files and directories.");
435
+ const fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW);
436
+ try {
437
+ const current = fstatSync(fd);
438
+ if (current.dev !== before.dev || current.ino !== before.ino)
439
+ throw new Error("Restore storage changed while opening.");
440
+ (options.io?.fsync ?? fsyncSync)(fd);
441
+ }
442
+ finally {
443
+ closeSync(fd);
444
+ }
445
+ };
446
+ const persistMarker = (rearm = false) => {
447
+ try {
448
+ writeFileSync(marker, "1\n", { mode: 0o600, flag: "wx" });
449
+ }
450
+ catch (error) {
451
+ if (!rearm || error.code !== "EEXIST")
452
+ throw error;
453
+ }
454
+ sync(marker);
455
+ sync(destination);
456
+ sync(dirname(destination));
457
+ };
428
458
  const created = [];
429
459
  let registry;
430
460
  try {
431
- await writeFile(join(destination, "restore-in-progress"), "1\n", {
432
- mode: 0o600,
433
- flag: "wx",
434
- flush: true,
435
- });
461
+ persistMarker();
436
462
  for (const [path, expected] of Object.entries(manifest.control)) {
437
463
  const bytes = await controlBytes(join(source, "control"), path);
438
464
  same(digest(bytes), expected);
439
465
  await mkdir(dirname(join(destination, path)), { recursive: true, mode: 0o700 });
466
+ for (let parent = dirname(join(destination, path)); parent !== destination; parent = dirname(parent))
467
+ directories.add(parent);
440
468
  await writeFile(join(destination, path), bytes, { mode: 0o600, flag: "wx", flush: true });
441
469
  }
442
- registry = new ProjectRegistry(destination);
470
+ registry = new ProjectRegistry(destination, Date.now, { fsync: options.io?.fsync });
443
471
  const rows = registry.all();
444
472
  if (registry.installationId !== manifest.installationId ||
445
473
  rows.some((p) => p.status !== "stopped") ||
@@ -450,6 +478,10 @@ export async function restoreProjectInstallation(options) {
450
478
  if (!auth.isInitialized())
451
479
  throw new Error("Backup administrator is not initialized.");
452
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"));
453
485
  registry.renewInstallationForRestore();
454
486
  const installationId = registry.installationId;
455
487
  for (const volume of manifest.volumes) {
@@ -472,7 +504,12 @@ export async function restoreProjectInstallation(options) {
472
504
  for (const path of ["project-runtime-secrets", "administrator-setup.txt", "environment.json"])
473
505
  await rm(join(destination, path), { recursive: true, force: true });
474
506
  registry.all();
475
- await rm(join(destination, "restore-in-progress"));
507
+ // Copied file bytes were flushed individually; their new directory entries
508
+ // must also survive a crash before the installation is advertised as usable.
509
+ for (const path of [...directories].sort((a, b) => b.length - a.length))
510
+ sync(path);
511
+ await (options.io?.removeMarker ?? rm)(marker);
512
+ sync(destination);
476
513
  return {
477
514
  destination,
478
515
  installationId,
@@ -482,6 +519,15 @@ export async function restoreProjectInstallation(options) {
482
519
  };
483
520
  }
484
521
  catch (error) {
522
+ let barrierError;
523
+ try {
524
+ // In particular, unlink may have succeeded before its directory fsync
525
+ // failed. Re-establish and persist the barrier before any cleanup awaits.
526
+ persistMarker(true);
527
+ }
528
+ catch (failure) {
529
+ barrierError = failure;
530
+ }
485
531
  // Only delete volumes created by this restore and still carrying its exact labels.
486
532
  for (const { name, expected } of created.reverse()) {
487
533
  try {
@@ -492,6 +538,8 @@ export async function restoreProjectInstallation(options) {
492
538
  /* Preserve a busy/changed volume for operator inspection. */
493
539
  }
494
540
  }
541
+ if (barrierError)
542
+ throw new AggregateError([error, barrierError], `Restore failed and its durable barrier could not be confirmed; keep ${destination} offline.`, { cause: error });
495
543
  throw error; // Keep the marked offline directory as recovery evidence; never advertise success.
496
544
  }
497
545
  finally {
@@ -0,0 +1,23 @@
1
+ import { type CloudAccountBinding } from "./account-binding.js";
2
+ import type { CloudAccountAuthority, CloudManagementSession, ProjectControlAuthority } from "./control-authority.js";
3
+ export declare const CLOUD_MANAGEMENT_AUTHORITY_VERSION = 2;
4
+ export declare const CLOUD_MANAGEMENT_COOKIE = "cs_cloud_management";
5
+ export declare function cloudManagementSessionView(session: CloudManagementSession): {
6
+ id: string;
7
+ kind: "cloud-management";
8
+ audience: string;
9
+ installationId: string;
10
+ accountOrigin: string;
11
+ accountId: string;
12
+ managementEpoch: string;
13
+ expiresAt: number;
14
+ };
15
+ /** An explicitly bound Account controls one existing single-operator installation. */
16
+ export declare function createCloudAccountAuth(options: {
17
+ binding: CloudAccountBinding;
18
+ installationId: string;
19
+ publicOrigin: string;
20
+ authority: CloudAccountAuthority;
21
+ onRevoke(sessionId: string): void;
22
+ now?: () => number;
23
+ }): ProjectControlAuthority;