larkin 0.4.2 → 0.4.6

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 (35) hide show
  1. package/README.md +5 -1
  2. package/artifacts/release/THIRD_PARTY_NOTICES.txt +218 -228
  3. package/assets/readme/larkin-github-cover.png +0 -0
  4. package/dist/agent/agent-state-store.cjs +1 -2
  5. package/dist/agent/agent-state-store.mjs +1 -2
  6. package/dist/app/agent-config.mjs +73 -6
  7. package/dist/app/agent-readiness.cjs +22 -2
  8. package/dist/app/agent-readiness.mjs +20 -2
  9. package/dist/app/binary-entry.mjs +6 -0
  10. package/dist/app/cli.mjs +4 -0
  11. package/dist/app/run.mjs +8 -0
  12. package/dist/app/runtime-process.mjs +13 -1
  13. package/dist/dashboard/dashboard-view-model.mjs +12 -3
  14. package/dist/feishu/host-shell.cjs +33 -10
  15. package/dist/feishu/host-shell.mjs +34 -11
  16. package/dist/platform/config.cjs +406 -33
  17. package/dist/platform/config.mjs +405 -33
  18. package/dist/platform/process-boundary-trace.cjs +108 -0
  19. package/dist/platform/process-boundary-trace.mjs +102 -0
  20. package/dist/runtime/pi-artifact-provenance.cjs +147 -0
  21. package/dist/runtime/pi-artifact-provenance.mjs +139 -0
  22. package/dist/runtime/pi-compaction-recovery.cjs +120 -40
  23. package/dist/runtime/pi-compaction-recovery.mjs +117 -40
  24. package/dist/runtime/pi-model-catalog.cjs +2 -2
  25. package/dist/runtime/pi-model-catalog.mjs +2 -2
  26. package/dist/runtime/pi-profile-migration.cjs +556 -0
  27. package/dist/runtime/pi-profile-migration.mjs +515 -0
  28. package/dist/runtime/pi-provider-config.cjs +1 -1
  29. package/dist/runtime/pi-provider-config.mjs +1 -1
  30. package/dist/runtime/runtime-adapters.mjs +97 -11
  31. package/dist/runtime/runtime-host.cjs +162 -39
  32. package/dist/runtime/runtime-host.mjs +163 -40
  33. package/dist/runtime/runtime-readiness.cjs +6 -4
  34. package/dist/runtime/runtime-readiness.mjs +6 -4
  35. package/package.json +3 -3
@@ -53,6 +53,7 @@ exports.runtimeConfigSignature = runtimeConfigSignature;
53
53
  exports.configApplyState = configApplyState;
54
54
  exports.markConfigApplied = markConfigApplied;
55
55
  exports.mutateConfig = mutateConfig;
56
+ exports.rollbackConfig = rollbackConfig;
56
57
  exports.commitSetupConfig = commitSetupConfig;
57
58
  exports.safeConfigView = safeConfigView;
58
59
  const crypto = __importStar(require("node:crypto"));
@@ -63,6 +64,7 @@ const root_layout_js_1 = require("./root-layout.cjs");
63
64
  const secure_metadata_js_1 = require("./secure-metadata.cjs");
64
65
  const runtime_model_catalog_js_1 = require("../runtime/runtime-model-catalog.cjs");
65
66
  const process_inspect_cjs_1 = __importDefault(require("./process-inspect.cjs"));
67
+ const pi_profile_migration_js_1 = require("../runtime/pi-profile-migration.cjs");
66
68
  const TOP_FIELDS_V3 = new Set(["version", "serverId", "activeAgent", "agents"]);
67
69
  const TOP_FIELDS_V4 = new Set(["version", "serverId", "mentionPolicy", "activeAgent", "agents"]);
68
70
  const AGENT_FIELDS_V3 = new Set(["runtime", "model", "effort", "noMentionChats", "createdAt"]);
@@ -76,6 +78,7 @@ const PI_MODEL = /^[A-Za-z0-9._-]+(\/[A-Za-z0-9._:@+-]+)?$/;
76
78
  const CODEX_MODEL = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
77
79
  const CLAUDE_MODEL = /^[A-Za-z0-9][A-Za-z0-9._\[\]-]{0,127}$/;
78
80
  const CONFIG_LIMIT_BYTES = 1024 * 1024;
81
+ const PROFILE_MIGRATION_ROLLBACK_LIMIT_BYTES = 64 * 1024 * 1024;
79
82
  const LOCK_RETRIES = 200;
80
83
  const MALFORMED_LOCK_GRACE_MS = 5_000;
81
84
  let runtimeModels;
@@ -268,23 +271,36 @@ function assertPrivateConfigMetadata(metadata, label = "配置文件") {
268
271
  if (!(0, secure_metadata_js_1.exactMode)(metadata, 0o600))
269
272
  throw new Error(`${label} 权限必须是 0600`);
270
273
  }
271
- function readPrivateFile(file, root, limit, label) {
274
+ function readBoundedPrivateFile(file, limit, label) {
272
275
  let fd = null;
273
276
  try {
274
- const rootReal = fs.realpathSync(root);
275
- const parentReal = fs.realpathSync(path.dirname(file));
276
- if (parentReal !== rootReal)
277
- throw new Error(`${label} 必须位于 canonical config root 内`);
277
+ const initial = fs.lstatSync(file);
278
+ if (initial.isSymbolicLink())
279
+ throw new Error(`${label} 不允许 symlink`);
280
+ if (!initial.isFile())
281
+ throw new Error(`${label} 必须是普通文件`);
278
282
  fd = fs.openSync(file, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0));
279
- const stat = fs.fstatSync(fd);
280
- assertPrivateConfigMetadata({ regularFile: stat.isFile(), uid: stat.uid, mode: stat.mode }, label);
281
- if (stat.size > limit)
283
+ const before = fs.fstatSync(fd);
284
+ assertPrivateConfigMetadata({ regularFile: before.isFile(), uid: before.uid, mode: before.mode }, label);
285
+ if (before.nlink !== 1)
286
+ throw new Error(`${label} 必须是未共享的普通文件`);
287
+ if (before.size > limit)
282
288
  throw new Error(`${label} 超过 ${limit} bytes`);
283
- return fs.readFileSync(fd);
289
+ const bytes = Buffer.allocUnsafe(before.size);
290
+ let offset = 0;
291
+ while (offset < before.size) {
292
+ const read = fs.readSync(fd, bytes, offset, before.size - offset, null);
293
+ if (read === 0)
294
+ throw new Error(`${label} 读取期间发生变化`);
295
+ offset += read;
296
+ }
297
+ const after = fs.fstatSync(fd);
298
+ if (after.dev !== before.dev || after.ino !== before.ino || after.size !== before.size || after.nlink !== before.nlink
299
+ || after.uid !== before.uid || after.gid !== before.gid || after.mode !== before.mode)
300
+ throw new Error(`${label} 读取期间发生变化`);
301
+ return bytes;
284
302
  }
285
303
  catch (error) {
286
- if (error.code === "ENOENT")
287
- return null;
288
304
  if (error.code === "ELOOP")
289
305
  throw new Error(`${label} 不允许 symlink`);
290
306
  throw error;
@@ -294,6 +310,20 @@ function readPrivateFile(file, root, limit, label) {
294
310
  fs.closeSync(fd);
295
311
  }
296
312
  }
313
+ function readPrivateFile(file, root, limit, label) {
314
+ try {
315
+ const rootReal = fs.realpathSync(root);
316
+ const parentReal = fs.realpathSync(path.dirname(file));
317
+ if (parentReal !== rootReal)
318
+ throw new Error(`${label} 必须位于 canonical config root 内`);
319
+ return readBoundedPrivateFile(file, limit, label);
320
+ }
321
+ catch (error) {
322
+ if (error.code === "ENOENT")
323
+ return null;
324
+ throw error;
325
+ }
326
+ }
297
327
  function readConfigFile(file, root = path.dirname(file)) {
298
328
  try {
299
329
  const bytes = readPrivateFile(file, root, CONFIG_LIMIT_BYTES, "配置文件");
@@ -381,23 +411,26 @@ function runtimeConfigSignature(config, agentId) {
381
411
  return agentSignature(config, agentId);
382
412
  }
383
413
  function applyStateFile(configDir) { return path.join(configDir, "config-apply-state.json"); }
414
+ function validateApplyState(value) {
415
+ if (!isPlainObject(value) || value.version !== 1 || typeof value.persistedRevision !== "string" || !isPlainObject(value.agents)) {
416
+ throw new Error("config apply state 无效");
417
+ }
418
+ const agents = {};
419
+ for (const [agentId, record] of Object.entries(value.agents)) {
420
+ if (!APP_ID.test(agentId) || !isPlainObject(record) || typeof record.persistedSignature !== "string"
421
+ || (record.appliedSignature !== null && typeof record.appliedSignature !== "string"))
422
+ throw new Error("config apply state Agent 记录无效");
423
+ agents[agentId] = { persistedSignature: record.persistedSignature, appliedSignature: record.appliedSignature };
424
+ }
425
+ return { version: 1, persistedRevision: value.persistedRevision, agents };
426
+ }
384
427
  function readApplyFile(configDir) {
385
428
  const file = applyStateFile(configDir);
429
+ const bytes = readPrivateFile(file, configDir, 256 * 1024, "config apply state");
430
+ if (bytes === null)
431
+ return { version: 1, persistedRevision: "unknown", agents: {} };
386
432
  try {
387
- const bytes = readPrivateFile(file, configDir, 256 * 1024, "config apply state");
388
- if (bytes === null)
389
- return { version: 1, persistedRevision: "unknown", agents: {} };
390
- const value = JSON.parse(bytes.toString("utf8"));
391
- if (value.version !== 1 || typeof value.persistedRevision !== "string" || !isPlainObject(value.agents))
392
- throw new Error("config apply state 无效");
393
- const agents = {};
394
- for (const [agentId, record] of Object.entries(value.agents)) {
395
- if (!APP_ID.test(agentId) || !isPlainObject(record) || typeof record.persistedSignature !== "string"
396
- || (record.appliedSignature !== null && typeof record.appliedSignature !== "string"))
397
- throw new Error("config apply state Agent 记录无效");
398
- agents[agentId] = { persistedSignature: record.persistedSignature, appliedSignature: record.appliedSignature };
399
- }
400
- return { version: 1, persistedRevision: value.persistedRevision, agents };
433
+ return validateApplyState(JSON.parse(bytes.toString("utf8")));
401
434
  }
402
435
  catch (error) {
403
436
  throw error;
@@ -406,6 +439,115 @@ function readApplyFile(configDir) {
406
439
  function writeApplyFile(configDir, value) {
407
440
  atomicWriteConfig(applyStateFile(configDir), value);
408
441
  }
442
+ function rollbackJournalFile(root) { return path.join(root, CONFIG_ROLLBACK_JOURNAL_FILE); }
443
+ function migrationJournalFile(root) { return path.join(root, ".pi-profile-migration-journal.json"); }
444
+ function assertMigrationTarget(root, agentId, migration) {
445
+ if (migration.agentId !== agentId || path.resolve(migration.targetDir) !== path.join(path.resolve(root), "providers", "pi", agentId)) {
446
+ throw new Error("Pi profile migration target is invalid");
447
+ }
448
+ }
449
+ function restoreTargetApplyState(root, targetAgentId, beforeRevision, before) {
450
+ const current = readApplyFile(root);
451
+ const agents = { ...current.agents };
452
+ if (before.agents[targetAgentId])
453
+ agents[targetAgentId] = { ...before.agents[targetAgentId] };
454
+ else
455
+ delete agents[targetAgentId];
456
+ writeApplyFile(root, { version: 1, persistedRevision: beforeRevision, agents });
457
+ }
458
+ function recoverRollbackJournal(root) {
459
+ const file = rollbackJournalFile(root);
460
+ let bytes;
461
+ try {
462
+ bytes = readPrivateFile(file, root, PROFILE_MIGRATION_ROLLBACK_LIMIT_BYTES, "config rollback journal");
463
+ }
464
+ catch (error) {
465
+ throw error;
466
+ }
467
+ if (bytes === null)
468
+ return;
469
+ let journal;
470
+ try {
471
+ journal = JSON.parse(bytes.toString("utf8"));
472
+ }
473
+ catch {
474
+ throw new Error("config rollback journal is invalid");
475
+ }
476
+ if (journal.version !== 1 || typeof journal.targetAgentId !== "string" || !APP_ID.test(journal.targetAgentId)
477
+ || typeof journal.expectedAfterRevision !== "string" || typeof journal.beforeRevision !== "string"
478
+ || typeof journal.beforeConfigBytes !== "string" || !isPlainObject(journal.beforeApplyState)) {
479
+ throw new Error("config rollback journal is invalid");
480
+ }
481
+ const beforeBytes = Buffer.from(journal.beforeConfigBytes, "base64");
482
+ if (beforeBytes.length === 0 || revision(beforeBytes) !== journal.beforeRevision)
483
+ throw new Error("config rollback journal is invalid");
484
+ const beforeRaw = JSON.parse(beforeBytes.toString("utf8"));
485
+ normalizeConfig(beforeRaw, root);
486
+ validateApplyState(journal.beforeApplyState);
487
+ if (journal.migration !== undefined) {
488
+ (0, pi_profile_migration_js_1.validatePiProfileMigrationState)(journal.migration);
489
+ assertMigrationTarget(root, journal.targetAgentId, journal.migration);
490
+ }
491
+ const current = readConfigFile(path.join(root, "config.json"), root);
492
+ const currentRevision = revision(current.bytes);
493
+ if (currentRevision === journal.expectedAfterRevision) {
494
+ if (journal.migration)
495
+ (0, pi_profile_migration_js_1.rollbackPiProfileMigration)(journal.migration);
496
+ atomicWriteBytes(path.join(root, "config.json"), beforeBytes);
497
+ restoreTargetApplyState(root, journal.targetAgentId, journal.beforeRevision, journal.beforeApplyState);
498
+ }
499
+ else if (currentRevision === journal.beforeRevision) {
500
+ // The config replacement may have reached disk before apply-state restoration.
501
+ // Finish that phase before deleting the journal so recovery is complete.
502
+ restoreTargetApplyState(root, journal.targetAgentId, journal.beforeRevision, journal.beforeApplyState);
503
+ }
504
+ else {
505
+ throw new Error("config rollback journal conflicts with the current configuration");
506
+ }
507
+ fs.unlinkSync(file);
508
+ (0, secure_metadata_js_1.fsyncDirectoryOf)(file);
509
+ }
510
+ function recoverMigrationJournal(root) {
511
+ const file = migrationJournalFile(root);
512
+ const bytes = readPrivateFile(file, root, PROFILE_MIGRATION_ROLLBACK_LIMIT_BYTES, "Pi profile migration journal");
513
+ if (bytes === null)
514
+ return;
515
+ let journal;
516
+ try {
517
+ journal = JSON.parse(bytes.toString("utf8"));
518
+ }
519
+ catch {
520
+ throw new Error("Pi profile migration journal is invalid");
521
+ }
522
+ if (journal.version !== 1 || journal.phase !== "forward" || typeof journal.targetAgentId !== "string"
523
+ || typeof journal.beforeRevision !== "string" || typeof journal.expectedAfterRevision !== "string" || !journal.migration) {
524
+ throw new Error("Pi profile migration journal is invalid");
525
+ }
526
+ (0, pi_profile_migration_js_1.validatePiProfileMigrationState)(journal.migration);
527
+ assertMigrationTarget(root, journal.targetAgentId, journal.migration);
528
+ const current = readConfigFile(path.join(root, "config.json"), root);
529
+ const currentRevision = revision(current.bytes);
530
+ if (currentRevision === journal.beforeRevision) {
531
+ // Forward import did not publish config. The migration helper restores only
532
+ // files still matching its expected post-import hashes.
533
+ try {
534
+ (0, pi_profile_migration_js_1.rollbackPiProfileMigration)(journal.migration);
535
+ }
536
+ catch (error) {
537
+ throw new Error(`Pi profile migration recovery refused: ${error instanceof Error ? error.message : String(error)}`);
538
+ }
539
+ }
540
+ else if (currentRevision === journal.expectedAfterRevision) {
541
+ // Config won the race; verify the provider target before finalizing.
542
+ const migration = journal.migration;
543
+ (0, pi_profile_migration_js_1.assertPiProfileMigrationAfterState)(migration);
544
+ }
545
+ else
546
+ throw new Error("Pi profile migration journal conflicts with the current configuration");
547
+ (0, pi_profile_migration_js_1.releasePiProfileMigrationLock)(journal.migration);
548
+ fs.unlinkSync(file);
549
+ (0, secure_metadata_js_1.fsyncDirectoryOf)(file);
550
+ }
409
551
  function configApplyState(env, config) {
410
552
  let state;
411
553
  try {
@@ -482,12 +624,21 @@ function applyMutation(config, mutation) {
482
624
  agent.noMentionChats = Object.entries(policies).filter(([, value]) => value === "free").map(([chatId]) => chatId);
483
625
  return { scope: "chat", agentId: mutation.agentId, runtimeChange: false, affectedAgentIds: [mutation.agentId] };
484
626
  }
485
- if (mutation.kind === "set-agent-runtime") {
627
+ if (mutation.kind === "set-agent-pi-distribution") {
628
+ if (agent.runtime !== "pi")
629
+ throw new Error(`Agent ${mutation.agentId} 不是 Pi runtime`);
630
+ if (mutation.distribution !== "builtin" && mutation.distribution !== "external")
631
+ throw new Error("Pi distribution 只允许 builtin/external");
632
+ agent.piDistribution = mutation.distribution;
633
+ }
634
+ else if (mutation.kind === "set-agent-runtime") {
486
635
  if (!loadRuntimeModels()[mutation.runtime])
487
636
  throw new Error(`未知 runtime:${mutation.runtime}`);
488
637
  assertModel(mutation.runtime, mutation.model || "default");
489
638
  agent.runtime = mutation.runtime;
490
639
  agent.model = mutation.model || "default";
640
+ if (mutation.runtime !== "pi")
641
+ delete agent.piDistribution;
491
642
  delete agent.effort;
492
643
  }
493
644
  else if (mutation.kind === "set-agent-model") {
@@ -606,8 +757,52 @@ function reclaimStaleLock(file, snapshot, owner) {
606
757
  }
607
758
  }
608
759
  }
760
+ function isKnownSystemSymlink(current) {
761
+ return current === "/tmp" || current === "/var" || current === "/private/tmp" || current === "/private/var"
762
+ || current === "/var/folders" || /^\/var\/folders\/[^/]+$/.test(current)
763
+ || current === "/private/var/folders" || /^\/private\/var\/folders\/[^/]+$/.test(current);
764
+ }
765
+ function assertNoSymlinkAncestors(target) {
766
+ const resolved = path.resolve(target);
767
+ const root = path.parse(resolved).root;
768
+ let current = root;
769
+ for (const part of path.relative(root, resolved).split(path.sep).filter(Boolean)) {
770
+ current = path.join(current, part);
771
+ let stat;
772
+ try {
773
+ stat = fs.lstatSync(current);
774
+ }
775
+ catch (error) {
776
+ if (error.code === "ENOENT")
777
+ return;
778
+ throw error;
779
+ }
780
+ if (stat.isSymbolicLink() && !isKnownSystemSymlink(current))
781
+ throw new Error("config path contains a symlink ancestor");
782
+ if (!stat.isDirectory() && !stat.isSymbolicLink())
783
+ throw new Error("config path ancestor is not a directory");
784
+ }
785
+ }
786
+ function assertConfigRoot(root) {
787
+ const stat = fs.lstatSync(root);
788
+ if (!stat.isDirectory() || stat.isSymbolicLink())
789
+ throw new Error("config root is unsafe");
790
+ if (process.platform !== "win32" && (stat.mode & 0o777) !== 0o700)
791
+ throw new Error("config root must be 0700");
792
+ if (typeof process.getuid === "function" && stat.uid !== process.getuid())
793
+ throw new Error("config root owner is unsafe");
794
+ }
609
795
  function withConfigLock(layout, action) {
796
+ assertNoSymlinkAncestors(layout.root);
610
797
  fs.mkdirSync(layout.root, { recursive: true, mode: 0o700 });
798
+ const rootStat = fs.lstatSync(layout.root);
799
+ if (!rootStat.isDirectory() || rootStat.isSymbolicLink())
800
+ throw new Error("config root is unsafe");
801
+ if (typeof process.getuid === "function" && rootStat.uid !== process.getuid())
802
+ throw new Error("config root owner is unsafe");
803
+ if (process.platform !== "win32" && (rootStat.mode & 0o777) !== 0o700)
804
+ fs.chmodSync(layout.root, 0o700);
805
+ assertConfigRoot(layout.root);
611
806
  const lock = `${layout.configFile}.lock`;
612
807
  const owner = currentLockRecord();
613
808
  let acquired = false;
@@ -621,6 +816,7 @@ function withConfigLock(layout, action) {
621
816
  finally {
622
817
  fs.closeSync(fd);
623
818
  }
819
+ (0, secure_metadata_js_1.fsyncDirectoryOf)(lock);
624
820
  acquired = true;
625
821
  break;
626
822
  }
@@ -636,6 +832,8 @@ function withConfigLock(layout, action) {
636
832
  if (!acquired)
637
833
  throw new Error("配置正被其他进程修改,请稍后重试");
638
834
  try {
835
+ recoverMigrationJournal(layout.root);
836
+ recoverRollbackJournal(layout.root);
639
837
  return action();
640
838
  }
641
839
  finally {
@@ -643,15 +841,113 @@ function withConfigLock(layout, action) {
643
841
  if (current?.record?.nonce === owner.nonce && current.record.pid === owner.pid) {
644
842
  try {
645
843
  fs.unlinkSync(lock);
844
+ (0, secure_metadata_js_1.fsyncDirectoryOf)(lock);
646
845
  }
647
846
  catch { /* best effort */ }
648
847
  }
649
848
  }
650
849
  }
651
- function atomicWriteConfig(file, value) {
652
- const bytes = Buffer.from(`${JSON.stringify(value, null, 2)}\n`, "utf8");
653
- if (bytes.length > CONFIG_LIMIT_BYTES)
654
- throw new Error(`配置文件超过 ${CONFIG_LIMIT_BYTES} bytes`);
850
+ const CONFIG_ROLLBACK_JOURNAL_FILE = ".config-rollback-journal.json";
851
+ function assertSnapshotPath(file, root, forbidden = []) {
852
+ const resolvedRoot = path.resolve(root);
853
+ const resolvedFile = path.resolve(file);
854
+ if (forbidden.some((candidate) => resolvedFile === path.resolve(candidate)))
855
+ throw new Error("config snapshot path is reserved");
856
+ assertNoSymlinkAncestors(resolvedRoot);
857
+ assertNoSymlinkAncestors(path.dirname(resolvedFile));
858
+ const rootStat = fs.lstatSync(resolvedRoot);
859
+ if (!rootStat.isDirectory() || rootStat.isSymbolicLink())
860
+ throw new Error("config root is unsafe");
861
+ if (process.platform !== "win32" && (rootStat.mode & 0o777) !== 0o700)
862
+ throw new Error("config root must be 0700");
863
+ if (typeof process.getuid === "function" && rootStat.uid !== process.getuid())
864
+ throw new Error("config root owner is unsafe");
865
+ const parent = path.dirname(resolvedFile);
866
+ const rootReal = fs.realpathSync(resolvedRoot);
867
+ const parentReal = fs.realpathSync(parent);
868
+ const relative = path.relative(rootReal, parentReal);
869
+ if (relative.startsWith(`..${path.sep}`) || relative === ".." || path.isAbsolute(relative)) {
870
+ throw new Error("config snapshot parent must be inside the canonical config root");
871
+ }
872
+ const parentStat = fs.lstatSync(parent);
873
+ if (!parentStat.isDirectory() || parentStat.isSymbolicLink())
874
+ throw new Error("config snapshot parent is unsafe");
875
+ if (process.platform !== "win32" && (parentStat.mode & 0o777) !== 0o700)
876
+ throw new Error("config snapshot parent must be 0700");
877
+ if (typeof process.getuid === "function" && parentStat.uid !== process.getuid())
878
+ throw new Error("config snapshot parent owner is unsafe");
879
+ try {
880
+ const stat = fs.lstatSync(resolvedFile);
881
+ if (stat.isSymbolicLink() || !stat.isFile() || stat.nlink !== 1)
882
+ throw new Error("config snapshot path is unsafe");
883
+ if (process.platform !== "win32" && (stat.mode & 0o777) !== 0o600)
884
+ throw new Error("config snapshot must be 0600");
885
+ if (typeof process.getuid === "function" && stat.uid !== process.getuid())
886
+ throw new Error("config snapshot owner is unsafe");
887
+ }
888
+ catch (error) {
889
+ if (error.code !== "ENOENT")
890
+ throw error;
891
+ }
892
+ }
893
+ function writeConfigSnapshot(file, root, snapshot) {
894
+ assertSnapshotPath(file, root, [path.join(root, CONFIG_ROLLBACK_JOURNAL_FILE), path.join(root, "config.json"), path.join(root, "config-apply-state.json")]);
895
+ const bytes = Buffer.from(`${JSON.stringify(snapshot, null, 2)}\n`, "utf8");
896
+ if (bytes.length > PROFILE_MIGRATION_ROLLBACK_LIMIT_BYTES)
897
+ throw new Error("config snapshot is too large");
898
+ const temporary = `${file}.${process.pid}.${crypto.randomUUID()}.tmp`;
899
+ const fd = fs.openSync(temporary, "wx", 0o600);
900
+ try {
901
+ fs.writeFileSync(fd, bytes);
902
+ fs.fsyncSync(fd);
903
+ }
904
+ finally {
905
+ fs.closeSync(fd);
906
+ }
907
+ try {
908
+ fs.renameSync(temporary, file);
909
+ fs.chmodSync(file, 0o600);
910
+ (0, secure_metadata_js_1.fsyncDirectoryOf)(file);
911
+ }
912
+ catch (error) {
913
+ try {
914
+ fs.unlinkSync(temporary);
915
+ }
916
+ catch { /* best effort */ }
917
+ throw error;
918
+ }
919
+ }
920
+ function readConfigSnapshot(file, root) {
921
+ assertSnapshotPath(file, root, [path.join(root, CONFIG_ROLLBACK_JOURNAL_FILE), path.join(root, "config.json"), path.join(root, "config-apply-state.json")]);
922
+ const parsed = JSON.parse(readBoundedPrivateFile(file, PROFILE_MIGRATION_ROLLBACK_LIMIT_BYTES, "config snapshot").toString("utf8"));
923
+ if (parsed.version !== 2 || typeof parsed.targetAgentId !== "string" || !APP_ID.test(parsed.targetAgentId)
924
+ || typeof parsed.beforeRevision !== "string" || typeof parsed.afterRevision !== "string"
925
+ || typeof parsed.afterSignature !== "string" || !parsed.beforeConfig || typeof parsed.beforeConfigBytes !== "string"
926
+ || !isPlainObject(parsed.beforeApplyState)) {
927
+ throw new Error("config snapshot is invalid");
928
+ }
929
+ const beforeBytes = Buffer.from(parsed.beforeConfigBytes, "base64");
930
+ if (beforeBytes.length === 0 || revision(beforeBytes) !== parsed.beforeRevision)
931
+ throw new Error("config snapshot before revision is invalid");
932
+ let beforeRaw;
933
+ try {
934
+ beforeRaw = JSON.parse(beforeBytes.toString("utf8"));
935
+ }
936
+ catch {
937
+ throw new Error("config snapshot before config is invalid");
938
+ }
939
+ if (JSON.stringify(beforeRaw) !== JSON.stringify(parsed.beforeConfig))
940
+ throw new Error("config snapshot before config does not match bytes");
941
+ validateApplyState(parsed.beforeApplyState);
942
+ if (parsed.migration !== undefined) {
943
+ (0, pi_profile_migration_js_1.validatePiProfileMigrationState)(parsed.migration);
944
+ assertMigrationTarget(root, parsed.targetAgentId, parsed.migration);
945
+ }
946
+ return parsed;
947
+ }
948
+ function atomicWriteBytes(file, bytes, limit = CONFIG_LIMIT_BYTES) {
949
+ if (bytes.length > limit)
950
+ throw new Error(`${limit === CONFIG_LIMIT_BYTES ? "配置文件" : "配置事务文件"}超过 ${limit} bytes`);
655
951
  const temporary = `${file}.${process.pid}.${crypto.randomUUID()}.tmp`;
656
952
  const fd = fs.openSync(temporary, "wx", 0o600);
657
953
  try {
@@ -675,7 +971,13 @@ function atomicWriteConfig(file, value) {
675
971
  }
676
972
  return bytes;
677
973
  }
678
- function mutateConfig(env, mutation, authority) {
974
+ function atomicWriteConfig(file, value, limit = CONFIG_LIMIT_BYTES) {
975
+ const bytes = Buffer.from(`${JSON.stringify(value, null, 2)}\n`, "utf8");
976
+ if (bytes.length > limit)
977
+ throw new Error(`${limit === CONFIG_LIMIT_BYTES ? "配置文件" : "配置事务文件"}超过 ${limit} bytes`);
978
+ return atomicWriteBytes(file, bytes, limit);
979
+ }
980
+ function mutateConfig(env, mutation, authority, options = {}) {
679
981
  assertAuthority(authority, mutation);
680
982
  const layout = root_layout_js_1.TargetRootLayout.fromConfigDir(resolveConfigDir(env));
681
983
  return withConfigLock(layout, () => {
@@ -684,11 +986,43 @@ function mutateConfig(env, mutation, authority) {
684
986
  throw new Error(`没找到配置 ${layout.configFile},先跑 larkin setup`);
685
987
  const config = normalizeConfig(current.raw, layout.root);
686
988
  const priorSignatures = Object.fromEntries(Object.keys(config.agents).map((agentId) => [agentId, agentSignature(config, agentId)]));
989
+ if (options.importExternalProfile && (mutation.kind !== "set-agent-pi-distribution" || mutation.distribution !== "builtin")) {
990
+ throw new Error("external Pi profile import is only valid for builtin Pi distribution");
991
+ }
992
+ const migrationPlan = options.importExternalProfile && mutation.kind === "set-agent-pi-distribution"
993
+ ? (0, pi_profile_migration_js_1.preparePiProfileMigration)(env, layout.root, mutation.agentId, "builtin")
994
+ : undefined;
687
995
  const changed = applyMutation(config, mutation);
688
996
  const stored = toStored(config);
689
997
  normalizeConfig(stored, layout.root);
690
- const bytes = atomicWriteConfig(layout.configFile, stored);
998
+ const bytes = Buffer.from(`${JSON.stringify(stored, null, 2)}\n`, "utf8");
691
999
  const nextRevision = revision(bytes);
1000
+ const beforeApplyState = options.snapshotFile
1001
+ ? readApplyFile(layout.root)
1002
+ : (() => { try {
1003
+ return readApplyFile(layout.root);
1004
+ }
1005
+ catch {
1006
+ return { version: 1, persistedRevision: "unknown", agents: {} };
1007
+ } })();
1008
+ if (options.snapshotFile) {
1009
+ if (changed.affectedAgentIds.length !== 1 || !changed.agentId)
1010
+ throw new Error("config snapshot requires exactly one Agent mutation");
1011
+ writeConfigSnapshot(options.snapshotFile, layout.root, {
1012
+ version: 2, targetAgentId: changed.agentId, beforeRevision: revision(current.bytes), afterRevision: nextRevision,
1013
+ afterSignature: agentSignature(config, changed.agentId), beforeConfig: current.raw,
1014
+ beforeConfigBytes: current.bytes.toString("base64"), beforeApplyState,
1015
+ ...(migrationPlan ? { migration: migrationPlan.state } : {}),
1016
+ });
1017
+ }
1018
+ if (migrationPlan) {
1019
+ atomicWriteConfig(migrationJournalFile(layout.root), {
1020
+ version: 1, phase: "forward", targetAgentId: changed.agentId, beforeRevision: revision(current.bytes),
1021
+ expectedAfterRevision: nextRevision, migration: migrationPlan.state,
1022
+ }, PROFILE_MIGRATION_ROLLBACK_LIMIT_BYTES);
1023
+ (0, pi_profile_migration_js_1.applyPiProfileMigration)(migrationPlan);
1024
+ }
1025
+ atomicWriteConfig(layout.configFile, stored);
692
1026
  let fullyApplied = changed.affectedAgentIds.length === 0;
693
1027
  try {
694
1028
  const applyState = readApplyFile(layout.root);
@@ -711,6 +1045,17 @@ function mutateConfig(env, mutation, authority) {
711
1045
  catch {
712
1046
  fullyApplied = false; /* Config persistence remains authoritative. */
713
1047
  }
1048
+ if (migrationPlan) {
1049
+ (0, pi_profile_migration_js_1.releasePiProfileMigrationLock)(migrationPlan.state);
1050
+ try {
1051
+ fs.unlinkSync(migrationJournalFile(layout.root));
1052
+ (0, secure_metadata_js_1.fsyncDirectoryOf)(migrationJournalFile(layout.root));
1053
+ }
1054
+ catch (error) {
1055
+ if (error.code !== "ENOENT")
1056
+ throw error;
1057
+ }
1058
+ }
714
1059
  return {
715
1060
  revision: nextRevision, previousRevision: revision(current.bytes), changedScope: changed.scope,
716
1061
  ...(changed.agentId ? { agentId: changed.agentId } : {}), persisted: true,
@@ -718,6 +1063,34 @@ function mutateConfig(env, mutation, authority) {
718
1063
  };
719
1064
  });
720
1065
  }
1066
+ function rollbackConfig(env, snapshotFile) {
1067
+ const layout = root_layout_js_1.TargetRootLayout.fromConfigDir(resolveConfigDir(env));
1068
+ return withConfigLock(layout, () => {
1069
+ const snapshot = readConfigSnapshot(snapshotFile, layout.root);
1070
+ const beforeBytes = Buffer.from(snapshot.beforeConfigBytes, "base64");
1071
+ const beforeRaw = JSON.parse(beforeBytes.toString("utf8"));
1072
+ normalizeConfig(beforeRaw, layout.root);
1073
+ const current = readConfigFile(layout.configFile, layout.root);
1074
+ const currentConfig = current.raw === null ? null : normalizeConfig(current.raw, layout.root);
1075
+ if (!currentConfig || revision(current.bytes) !== snapshot.afterRevision
1076
+ || agentSignature(currentConfig, snapshot.targetAgentId) !== snapshot.afterSignature) {
1077
+ throw new Error("config changed after the snapshot; refusing rollback");
1078
+ }
1079
+ const journal = {
1080
+ version: 1, targetAgentId: snapshot.targetAgentId, expectedAfterRevision: snapshot.afterRevision,
1081
+ beforeRevision: snapshot.beforeRevision, beforeConfigBytes: snapshot.beforeConfigBytes, beforeApplyState: snapshot.beforeApplyState,
1082
+ ...(snapshot.migration ? { migration: snapshot.migration } : {}),
1083
+ };
1084
+ atomicWriteConfig(rollbackJournalFile(layout.root), journal, PROFILE_MIGRATION_ROLLBACK_LIMIT_BYTES);
1085
+ if (snapshot.migration)
1086
+ (0, pi_profile_migration_js_1.rollbackPiProfileMigration)(snapshot.migration);
1087
+ const bytes = atomicWriteBytes(layout.configFile, beforeBytes);
1088
+ restoreTargetApplyState(layout.root, snapshot.targetAgentId, snapshot.beforeRevision, snapshot.beforeApplyState);
1089
+ fs.unlinkSync(rollbackJournalFile(layout.root));
1090
+ (0, secure_metadata_js_1.fsyncDirectoryOf)(rollbackJournalFile(layout.root));
1091
+ return { revision: revision(bytes), agentId: snapshot.targetAgentId };
1092
+ });
1093
+ }
721
1094
  function commitSetupConfig(env, expectedRevision, nextStored) {
722
1095
  const layout = root_layout_js_1.TargetRootLayout.fromConfigDir(resolveConfigDir(env));
723
1096
  return withConfigLock(layout, () => {
@@ -732,7 +1105,7 @@ function commitSetupConfig(env, expectedRevision, nextStored) {
732
1105
  function safeConfigView(config, onlyAgentId, chatId, applyState) {
733
1106
  const entries = onlyAgentId ? [[onlyAgentId, config.agents[onlyAgentId]]] : Object.entries(config.agents);
734
1107
  const agents = entries.filter((entry) => Boolean(entry[1])).map(([agentId, agent]) => ({
735
- agentId, runtime: agent.runtime, model: agent.model, effort: agent.effort ?? null,
1108
+ agentId, runtime: agent.runtime, model: agent.model, piDistribution: agent.piDistribution ?? (agent.runtime === "pi" ? "external" : null), effort: agent.effort ?? null,
736
1109
  mention: {
737
1110
  override: agent.mentionPolicy ?? "inherit",
738
1111
  effective: agent.mentionPolicy ?? config.mentionPolicy,