@cjhyy/code-shell-server 0.9.35 → 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.
Files changed (32) hide show
  1. package/dist/bin/code-shell-cloud-account.d.ts +2 -0
  2. package/dist/bin/code-shell-cloud-account.js +53 -0
  3. package/dist/index.serve.d.ts +3 -0
  4. package/dist/index.serve.js +2 -0
  5. package/dist/mobile-remote/mobile-client-event-validator.js +10 -1
  6. package/dist/mobile-remote/room-history.d.ts +20 -0
  7. package/dist/mobile-remote/room-history.js +327 -0
  8. package/dist/mobile-remote/room-manager.d.ts +11 -0
  9. package/dist/mobile-remote/room-manager.js +107 -11
  10. package/dist/panels/management.js +12 -1
  11. package/dist/panels/runtime-services.js +32 -7
  12. package/dist/panels/types.d.ts +5 -3
  13. package/dist/project-runtime/account-adoption.d.ts +34 -0
  14. package/dist/project-runtime/account-adoption.js +280 -0
  15. package/dist/project-runtime/account-binding.d.ts +12 -0
  16. package/dist/project-runtime/account-binding.js +33 -0
  17. package/dist/project-runtime/backup.d.ts +7 -0
  18. package/dist/project-runtime/backup.js +56 -8
  19. package/dist/project-runtime/cloud-account-auth.d.ts +23 -0
  20. package/dist/project-runtime/cloud-account-auth.js +287 -0
  21. package/dist/project-runtime/control-authority.d.ts +46 -0
  22. package/dist/project-runtime/control-authority.js +15 -0
  23. package/dist/project-runtime/control-server.d.ts +3 -0
  24. package/dist/project-runtime/control-server.js +60 -24
  25. package/dist/project-runtime/lock.js +181 -12
  26. package/dist/project-runtime/manager.d.ts +12 -8
  27. package/dist/project-runtime/manager.js +56 -15
  28. package/dist/project-runtime/proxy.d.ts +3 -3
  29. package/dist/project-runtime/proxy.js +4 -1
  30. package/dist/project-runtime/registry.d.ts +14 -1
  31. package/dist/project-runtime/registry.js +91 -2
  32. package/package.json +7 -6
@@ -1,4 +1,5 @@
1
- import { chmodSync, closeSync, existsSync, fchmodSync, fstatSync, lstatSync, mkdirSync, openSync, readSync, realpathSync, readdirSync, renameSync, writeFileSync, appendFileSync, rmSync, } from "node:fs";
1
+ import { chmodSync, closeSync, existsSync, fchmodSync, fstatSync, fsyncSync, lstatSync, mkdirSync, openSync, readSync, realpathSync, readdirSync, renameSync, writeFileSync, appendFileSync, rmSync, } from "node:fs";
2
+ import { readRoomHistoryPage, scanRoomHistory, } from "./room-history.js";
2
3
  import { randomUUID } from "node:crypto";
3
4
  import { isAbsolute, join, relative, resolve, sep } from "node:path";
4
5
  /**
@@ -24,6 +25,17 @@ function isContained(root, target) {
24
25
  const rel = relative(root, target);
25
26
  return rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel);
26
27
  }
28
+ function syncRoomDirectory(path) {
29
+ if (process.platform === "win32")
30
+ return;
31
+ const fd = openSync(path, "r");
32
+ try {
33
+ fsyncSync(fd);
34
+ }
35
+ finally {
36
+ closeSync(fd);
37
+ }
38
+ }
27
39
  function readBoundedUtf8File(path, maxBytes) {
28
40
  const fd = openSync(path, "r");
29
41
  try {
@@ -105,7 +117,14 @@ function parseRoomMessage(value) {
105
117
  raw.type.length > 128) {
106
118
  return undefined;
107
119
  }
108
- for (const field of ["text", "tool", "summary", "reason", "toolId"]) {
120
+ for (const field of [
121
+ "text",
122
+ "tool",
123
+ "summary",
124
+ "reason",
125
+ "toolId",
126
+ "transcriptSource",
127
+ ]) {
109
128
  const item = raw[field];
110
129
  if (item !== undefined && (typeof item !== "string" || item.includes("\0")))
111
130
  return undefined;
@@ -235,6 +254,7 @@ export class RoomManager {
235
254
  /** User turns are echoed immediately for responsive room UX. When a
236
255
  * transcript follower later sees the same CLI-written user line, this queue
237
256
  * suppresses that duplicate. */
257
+ transcriptSources = new Map();
238
258
  pendingTranscriptUserEchoes = new Map();
239
259
  /**
240
260
  * Rooms whose agent events must be buffered instead of handled immediately.
@@ -251,12 +271,19 @@ export class RoomManager {
251
271
  * JSONL. Seeded lazily from disk on the first append after startup; dropped
252
272
  * when the room's messages are deleted so the next append re-derives it.
253
273
  */
274
+ messageStampByRoom = new Map();
275
+ messageStamp(id) {
276
+ const stat = lstatSync(this.msgPath(id));
277
+ return [stat.dev, stat.ino, stat.size, stat.mtimeMs, stat.ctimeMs].join(":");
278
+ }
254
279
  lastSeqByRoom = new Map();
255
280
  /** Throttled lastActiveAt bookkeeping — see touchLastActive. */
256
281
  pendingLastActive = new Map();
257
282
  lastActiveWrittenAt = new Map();
258
283
  now;
259
284
  rootRealPath;
285
+ rootIdentity;
286
+ failedWrites = new Set();
260
287
  /** Drop all pending AskUser entries for a room. Called when its agent goes
261
288
  * away (exit/close) so a request that can no longer be answered doesn't leak
262
289
  * in the map (small memory leak) and its approval card doesn't hang until the
@@ -281,6 +308,7 @@ export class RoomManager {
281
308
  throw new Error(`invalid room storage root: ${opts.rootDir}`);
282
309
  }
283
310
  this.rootRealPath = realpathSync(opts.rootDir);
311
+ this.rootIdentity = `${rootInfo.dev}:${rootInfo.ino}`;
284
312
  if (process.platform !== "win32")
285
313
  chmodSync(opts.rootDir, 0o700);
286
314
  }
@@ -288,7 +316,8 @@ export class RoomManager {
288
316
  const info = lstatSync(this.opts.rootDir);
289
317
  if (info.isSymbolicLink() ||
290
318
  !info.isDirectory() ||
291
- realpathSync(this.opts.rootDir) !== this.rootRealPath) {
319
+ realpathSync(this.opts.rootDir) !== this.rootRealPath ||
320
+ `${info.dev}:${info.ino}` !== this.rootIdentity) {
292
321
  throw new Error(`invalid room storage root: ${this.opts.rootDir}`);
293
322
  }
294
323
  }
@@ -318,7 +347,7 @@ export class RoomManager {
318
347
  const path = join(this.roomDir(id), name);
319
348
  if (existsSync(path)) {
320
349
  const info = lstatSync(path);
321
- if (info.isSymbolicLink() || !info.isFile())
350
+ if (info.isSymbolicLink() || !info.isFile() || info.nlink !== 1)
322
351
  throw new Error(`invalid room file: ${path}`);
323
352
  }
324
353
  return path;
@@ -337,7 +366,15 @@ export class RoomManager {
337
366
  encoding: "utf-8",
338
367
  mode: 0o600,
339
368
  });
369
+ const fd = openSync(temporary, "r");
370
+ try {
371
+ fsyncSync(fd);
372
+ }
373
+ finally {
374
+ closeSync(fd);
375
+ }
340
376
  renameSync(temporary, target);
377
+ syncRoomDirectory(this.roomDir(id));
341
378
  }
342
379
  finally {
343
380
  try {
@@ -354,6 +391,7 @@ export class RoomManager {
354
391
  if (Buffer.byteLength(serialized, "utf8") > MAX_ROOM_MESSAGE_BYTES) {
355
392
  throw new Error("room message exceeds the size limit");
356
393
  }
394
+ const created = !existsSync(target);
357
395
  const fd = openSync(target, "a+", 0o600);
358
396
  try {
359
397
  if (process.platform !== "win32")
@@ -367,6 +405,13 @@ export class RoomManager {
367
405
  prefix = "\n";
368
406
  }
369
407
  appendFileSync(fd, `${prefix}${serialized}\n`, "utf-8");
408
+ fsyncSync(fd);
409
+ if (created)
410
+ syncRoomDirectory(this.roomDir(id));
411
+ }
412
+ catch (error) {
413
+ this.failedWrites.add(id);
414
+ throw error;
370
415
  }
371
416
  finally {
372
417
  closeSync(fd);
@@ -408,6 +453,7 @@ export class RoomManager {
408
453
  linkedSessionMode: input.linkedSessionMode,
409
454
  };
410
455
  mkdirSync(this.roomDir(id), { recursive: true, mode: 0o700 });
456
+ syncRoomDirectory(this.opts.rootDir);
411
457
  this.writeRoomMeta(id, meta);
412
458
  // Audit anchor: the first line records how the room was opened (cwd +
413
459
  // permission mode), so messages.jsonl is self-describing for "what could
@@ -466,14 +512,20 @@ export class RoomManager {
466
512
  // host's event loop: a long-lived room progressively slowed down new
467
513
  // messages, heartbeats and every other WebSocket client. (Measured: ~32s to
468
514
  // write 2,000 short messages.)
515
+ if (this.failedWrites.has(id))
516
+ throw new Error("room persistence failed; reopen after inspecting its history");
469
517
  const cached = this.lastSeqByRoom.get(id);
470
518
  if (cached !== undefined) {
519
+ if (this.messageStampByRoom.get(id) !== this.messageStamp(id)) {
520
+ this.failedWrites.add(id);
521
+ throw new Error("room history changed outside this writer; inspect it before reopening");
522
+ }
471
523
  const next = cached + 1;
472
524
  this.lastSeqByRoom.set(id, next);
473
525
  return next;
474
526
  }
475
- const msgs = this.getMessages(id, 0);
476
- const next = msgs.length === 0 ? 1 : msgs[msgs.length - 1].seq + 1;
527
+ const path = this.msgPath(id);
528
+ const next = existsSync(path) ? scanRoomHistory(path, parseRoomMessage) + 1 : 1;
477
529
  this.lastSeqByRoom.set(id, next);
478
530
  return next;
479
531
  }
@@ -484,6 +536,7 @@ export class RoomManager {
484
536
  const msg = { seq, ts, ...partial };
485
537
  try {
486
538
  this.appendRoomMessage(id, msg);
539
+ this.messageStampByRoom.set(id, this.messageStamp(id));
487
540
  }
488
541
  catch (error) {
489
542
  // Sequence assignment is a commit effect, not an attempt counter. A
@@ -601,8 +654,29 @@ export class RoomManager {
601
654
  closeSync(fd);
602
655
  }
603
656
  }
657
+ getHistoryPage(id, request = {}) {
658
+ if (!this.getRoom(id))
659
+ throw new Error("room is missing or its metadata is corrupt");
660
+ return readRoomHistoryPage(this.roomDir(id), id, request, parseRoomMessage);
661
+ }
604
662
  latestSeq(id) {
605
- return this.getMessages(id).at(-1)?.seq ?? 0;
663
+ if (this.failedWrites.has(id))
664
+ throw new Error("room persistence failed");
665
+ const cached = this.lastSeqByRoom.get(id);
666
+ if (cached !== undefined)
667
+ return cached;
668
+ const path = this.msgPath(id);
669
+ return existsSync(path) ? scanRoomHistory(path, parseRoomMessage) : 0;
670
+ }
671
+ hasTranscriptHistory(id) {
672
+ let found = false;
673
+ const file = this.msgPath(id);
674
+ if (existsSync(file))
675
+ scanRoomHistory(file, parseRoomMessage, (message) => {
676
+ if (message.transcriptSource)
677
+ found = true;
678
+ });
679
+ return found;
606
680
  }
607
681
  beginTranscriptFollow(id) {
608
682
  if (this.getRoom(id))
@@ -614,9 +688,30 @@ export class RoomManager {
614
688
  /** Persist normalized transcript-tail messages through the same seq/history/
615
689
  * broadcast path as resident-agent output. */
616
690
  ingestTranscriptMessages(id, messages) {
617
- if (!this.transcriptFollowedRooms.has(id) || !this.getRoom(id))
691
+ if (!messages.length)
618
692
  return;
693
+ if (!this.transcriptFollowedRooms.has(id) || !this.getRoom(id))
694
+ throw new Error("transcript room is not active");
695
+ let sources = this.transcriptSources.get(id);
696
+ if (!sources) {
697
+ sources = new Set();
698
+ const path = this.msgPath(id);
699
+ if (existsSync(path))
700
+ scanRoomHistory(path, parseRoomMessage, (message) => {
701
+ if (message.transcriptSource)
702
+ sources.add(message.transcriptSource);
703
+ });
704
+ this.transcriptSources.set(id, sources);
705
+ }
619
706
  for (const message of messages) {
707
+ const source = message.transcriptSource;
708
+ if (source !== undefined &&
709
+ (typeof source !== "string" ||
710
+ source.length > 200 ||
711
+ !/^[0-9a-f-]{36}:[0-9]+:[0-9]+$/.test(source)))
712
+ throw new Error("invalid transcript source coordinate");
713
+ if (source && sources.has(source))
714
+ continue;
620
715
  if (message.from === "user" && typeof message.text === "string") {
621
716
  const text = stripRoomAttachmentBlock(message.text);
622
717
  const pending = this.pendingTranscriptUserEchoes.get(id);
@@ -628,15 +723,16 @@ export class RoomManager {
628
723
  pending.shift();
629
724
  if (pending?.length === 0)
630
725
  this.pendingTranscriptUserEchoes.delete(id);
631
- // `send` already persisted and broadcast this exact user turn. The
632
- // transcript remains authoritative for agent/tool output, but must not
633
- // render a second copy of the prompt.
634
726
  if (!matched)
635
727
  this.append(id, { ...message, text });
728
+ else if (source)
729
+ this.append(id, { from: "system", type: "transcript_echo", transcriptSource: source });
636
730
  }
637
731
  else {
638
732
  this.append(id, message);
639
733
  }
734
+ if (source)
735
+ sources.add(source);
640
736
  }
641
737
  }
642
738
  /**
@@ -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,
@@ -2,7 +2,7 @@ import { createHash, randomUUID } from "node:crypto";
2
2
  import { constants } from "node:fs";
3
3
  import { chmod, link, lstat, mkdir, open, opendir, realpath, rename, rm, writeFile, } from "node:fs/promises";
4
4
  import { basename, dirname, extname, isAbsolute, join, resolve, sep } from "node:path";
5
- import { acquireLockOnPath } from "@cjhyy/code-shell-core/internal";
5
+ import { acquireLockOnPath, acquireWorkspaceWriteLock } from "@cjhyy/code-shell-core/internal";
6
6
  import { DEFAULT_PANEL_APP_STORAGE_QUOTA_BYTES, panelAppStorageSnapshot, panelAppStorageChange, applyPanelAppStorageChange, panelAppStorageKey, panelAppStoragePath, preparePanelAppStorage, readPanelAppStorage, writePanelAppStorage, } from "./storage-store.js";
7
7
  const MAX_READ_BYTES = 480 * 1024;
8
8
  const MAX_WRITE_BYTES = 384 * 1024;
@@ -390,7 +390,15 @@ export class PanelRuntimeServices {
390
390
  const parent = await this.openPath(root, dirname(path), true);
391
391
  const stableTarget = join(parent.path, basename(path));
392
392
  const temporary = join(parent.path, `.${basename(path)}.${randomUUID()}.tmp`);
393
+ let releaseWorkspaceLock;
394
+ let outcome;
393
395
  try {
396
+ await parent.verify();
397
+ releaseWorkspaceLock = await acquireWorkspaceWriteLock(parent.path, basename(path));
398
+ await parent.verify();
399
+ await this.root(scope);
400
+ await authorized(scope);
401
+ await parent.verify();
394
402
  let modifiedAt = null, currentRevision = null, mode = 0o644;
395
403
  let existing;
396
404
  try {
@@ -459,17 +467,34 @@ export class PanelRuntimeServices {
459
467
  await rename(temporary, stableTarget);
460
468
  await chmod(stableTarget, mode).catch(() => { });
461
469
  const metadata = await lstat(stableTarget);
462
- return {
463
- path,
464
- size: bytes.length,
465
- modifiedAt: metadata.mtimeMs,
466
- revision: revision(bytes),
470
+ outcome = {
471
+ ok: true,
472
+ value: {
473
+ path,
474
+ size: bytes.length,
475
+ modifiedAt: metadata.mtimeMs,
476
+ revision: revision(bytes),
477
+ },
467
478
  };
468
479
  }
480
+ catch (error) {
481
+ outcome = { ok: false, error };
482
+ }
483
+ await rm(temporary, { force: true }).catch(() => { });
484
+ try {
485
+ await releaseWorkspaceLock?.();
486
+ }
487
+ catch (error) {
488
+ if (!outcome.ok)
489
+ throw new AggregateError([outcome.error, error], `${String(outcome.error)}; ${String(error)}`, { cause: error });
490
+ throw error;
491
+ }
469
492
  finally {
470
- await rm(temporary, { force: true }).catch(() => { });
471
493
  await parent.close();
472
494
  }
495
+ if (!outcome.ok)
496
+ throw outcome.error;
497
+ return outcome.value;
473
498
  });
474
499
  }
475
500
  }
@@ -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
+ }