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
@@ -6,6 +6,7 @@ import { TargetRootLayout, resolveConfigDir as resolveRootConfigDir } from "./ro
6
6
  import { exactMode, fsyncDirectoryOf } from "./secure-metadata.mjs";
7
7
  import { CURRENT_RUNTIME_MODELS } from "../runtime/runtime-model-catalog.mjs";
8
8
  import processInspect from "./process-inspect.cjs";
9
+ import { applyPiProfileMigration, assertPiProfileMigrationAfterState, preparePiProfileMigration, releasePiProfileMigrationLock, rollbackPiProfileMigration, validatePiProfileMigrationState, } from "../runtime/pi-profile-migration.mjs";
9
10
  const TOP_FIELDS_V3 = new Set(["version", "serverId", "activeAgent", "agents"]);
10
11
  const TOP_FIELDS_V4 = new Set(["version", "serverId", "mentionPolicy", "activeAgent", "agents"]);
11
12
  const AGENT_FIELDS_V3 = new Set(["runtime", "model", "effort", "noMentionChats", "createdAt"]);
@@ -19,6 +20,7 @@ const PI_MODEL = /^[A-Za-z0-9._-]+(\/[A-Za-z0-9._:@+-]+)?$/;
19
20
  const CODEX_MODEL = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
20
21
  const CLAUDE_MODEL = /^[A-Za-z0-9][A-Za-z0-9._\[\]-]{0,127}$/;
21
22
  const CONFIG_LIMIT_BYTES = 1024 * 1024;
23
+ const PROFILE_MIGRATION_ROLLBACK_LIMIT_BYTES = 64 * 1024 * 1024;
22
24
  const LOCK_RETRIES = 200;
23
25
  const MALFORMED_LOCK_GRACE_MS = 5_000;
24
26
  let runtimeModels;
@@ -211,23 +213,36 @@ export function assertPrivateConfigMetadata(metadata, label = "配置文件") {
211
213
  if (!exactMode(metadata, 0o600))
212
214
  throw new Error(`${label} 权限必须是 0600`);
213
215
  }
214
- function readPrivateFile(file, root, limit, label) {
216
+ function readBoundedPrivateFile(file, limit, label) {
215
217
  let fd = null;
216
218
  try {
217
- const rootReal = fs.realpathSync(root);
218
- const parentReal = fs.realpathSync(path.dirname(file));
219
- if (parentReal !== rootReal)
220
- throw new Error(`${label} 必须位于 canonical config root 内`);
219
+ const initial = fs.lstatSync(file);
220
+ if (initial.isSymbolicLink())
221
+ throw new Error(`${label} 不允许 symlink`);
222
+ if (!initial.isFile())
223
+ throw new Error(`${label} 必须是普通文件`);
221
224
  fd = fs.openSync(file, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0));
222
- const stat = fs.fstatSync(fd);
223
- assertPrivateConfigMetadata({ regularFile: stat.isFile(), uid: stat.uid, mode: stat.mode }, label);
224
- if (stat.size > limit)
225
+ const before = fs.fstatSync(fd);
226
+ assertPrivateConfigMetadata({ regularFile: before.isFile(), uid: before.uid, mode: before.mode }, label);
227
+ if (before.nlink !== 1)
228
+ throw new Error(`${label} 必须是未共享的普通文件`);
229
+ if (before.size > limit)
225
230
  throw new Error(`${label} 超过 ${limit} bytes`);
226
- return fs.readFileSync(fd);
231
+ const bytes = Buffer.allocUnsafe(before.size);
232
+ let offset = 0;
233
+ while (offset < before.size) {
234
+ const read = fs.readSync(fd, bytes, offset, before.size - offset, null);
235
+ if (read === 0)
236
+ throw new Error(`${label} 读取期间发生变化`);
237
+ offset += read;
238
+ }
239
+ const after = fs.fstatSync(fd);
240
+ if (after.dev !== before.dev || after.ino !== before.ino || after.size !== before.size || after.nlink !== before.nlink
241
+ || after.uid !== before.uid || after.gid !== before.gid || after.mode !== before.mode)
242
+ throw new Error(`${label} 读取期间发生变化`);
243
+ return bytes;
227
244
  }
228
245
  catch (error) {
229
- if (error.code === "ENOENT")
230
- return null;
231
246
  if (error.code === "ELOOP")
232
247
  throw new Error(`${label} 不允许 symlink`);
233
248
  throw error;
@@ -237,6 +252,20 @@ function readPrivateFile(file, root, limit, label) {
237
252
  fs.closeSync(fd);
238
253
  }
239
254
  }
255
+ function readPrivateFile(file, root, limit, label) {
256
+ try {
257
+ const rootReal = fs.realpathSync(root);
258
+ const parentReal = fs.realpathSync(path.dirname(file));
259
+ if (parentReal !== rootReal)
260
+ throw new Error(`${label} 必须位于 canonical config root 内`);
261
+ return readBoundedPrivateFile(file, limit, label);
262
+ }
263
+ catch (error) {
264
+ if (error.code === "ENOENT")
265
+ return null;
266
+ throw error;
267
+ }
268
+ }
240
269
  function readConfigFile(file, root = path.dirname(file)) {
241
270
  try {
242
271
  const bytes = readPrivateFile(file, root, CONFIG_LIMIT_BYTES, "配置文件");
@@ -324,23 +353,26 @@ export function runtimeConfigSignature(config, agentId) {
324
353
  return agentSignature(config, agentId);
325
354
  }
326
355
  function applyStateFile(configDir) { return path.join(configDir, "config-apply-state.json"); }
356
+ function validateApplyState(value) {
357
+ if (!isPlainObject(value) || value.version !== 1 || typeof value.persistedRevision !== "string" || !isPlainObject(value.agents)) {
358
+ throw new Error("config apply state 无效");
359
+ }
360
+ const agents = {};
361
+ for (const [agentId, record] of Object.entries(value.agents)) {
362
+ if (!APP_ID.test(agentId) || !isPlainObject(record) || typeof record.persistedSignature !== "string"
363
+ || (record.appliedSignature !== null && typeof record.appliedSignature !== "string"))
364
+ throw new Error("config apply state Agent 记录无效");
365
+ agents[agentId] = { persistedSignature: record.persistedSignature, appliedSignature: record.appliedSignature };
366
+ }
367
+ return { version: 1, persistedRevision: value.persistedRevision, agents };
368
+ }
327
369
  function readApplyFile(configDir) {
328
370
  const file = applyStateFile(configDir);
371
+ const bytes = readPrivateFile(file, configDir, 256 * 1024, "config apply state");
372
+ if (bytes === null)
373
+ return { version: 1, persistedRevision: "unknown", agents: {} };
329
374
  try {
330
- const bytes = readPrivateFile(file, configDir, 256 * 1024, "config apply state");
331
- if (bytes === null)
332
- return { version: 1, persistedRevision: "unknown", agents: {} };
333
- const value = JSON.parse(bytes.toString("utf8"));
334
- if (value.version !== 1 || typeof value.persistedRevision !== "string" || !isPlainObject(value.agents))
335
- throw new Error("config apply state 无效");
336
- const agents = {};
337
- for (const [agentId, record] of Object.entries(value.agents)) {
338
- if (!APP_ID.test(agentId) || !isPlainObject(record) || typeof record.persistedSignature !== "string"
339
- || (record.appliedSignature !== null && typeof record.appliedSignature !== "string"))
340
- throw new Error("config apply state Agent 记录无效");
341
- agents[agentId] = { persistedSignature: record.persistedSignature, appliedSignature: record.appliedSignature };
342
- }
343
- return { version: 1, persistedRevision: value.persistedRevision, agents };
375
+ return validateApplyState(JSON.parse(bytes.toString("utf8")));
344
376
  }
345
377
  catch (error) {
346
378
  throw error;
@@ -349,6 +381,115 @@ function readApplyFile(configDir) {
349
381
  function writeApplyFile(configDir, value) {
350
382
  atomicWriteConfig(applyStateFile(configDir), value);
351
383
  }
384
+ function rollbackJournalFile(root) { return path.join(root, CONFIG_ROLLBACK_JOURNAL_FILE); }
385
+ function migrationJournalFile(root) { return path.join(root, ".pi-profile-migration-journal.json"); }
386
+ function assertMigrationTarget(root, agentId, migration) {
387
+ if (migration.agentId !== agentId || path.resolve(migration.targetDir) !== path.join(path.resolve(root), "providers", "pi", agentId)) {
388
+ throw new Error("Pi profile migration target is invalid");
389
+ }
390
+ }
391
+ function restoreTargetApplyState(root, targetAgentId, beforeRevision, before) {
392
+ const current = readApplyFile(root);
393
+ const agents = { ...current.agents };
394
+ if (before.agents[targetAgentId])
395
+ agents[targetAgentId] = { ...before.agents[targetAgentId] };
396
+ else
397
+ delete agents[targetAgentId];
398
+ writeApplyFile(root, { version: 1, persistedRevision: beforeRevision, agents });
399
+ }
400
+ function recoverRollbackJournal(root) {
401
+ const file = rollbackJournalFile(root);
402
+ let bytes;
403
+ try {
404
+ bytes = readPrivateFile(file, root, PROFILE_MIGRATION_ROLLBACK_LIMIT_BYTES, "config rollback journal");
405
+ }
406
+ catch (error) {
407
+ throw error;
408
+ }
409
+ if (bytes === null)
410
+ return;
411
+ let journal;
412
+ try {
413
+ journal = JSON.parse(bytes.toString("utf8"));
414
+ }
415
+ catch {
416
+ throw new Error("config rollback journal is invalid");
417
+ }
418
+ if (journal.version !== 1 || typeof journal.targetAgentId !== "string" || !APP_ID.test(journal.targetAgentId)
419
+ || typeof journal.expectedAfterRevision !== "string" || typeof journal.beforeRevision !== "string"
420
+ || typeof journal.beforeConfigBytes !== "string" || !isPlainObject(journal.beforeApplyState)) {
421
+ throw new Error("config rollback journal is invalid");
422
+ }
423
+ const beforeBytes = Buffer.from(journal.beforeConfigBytes, "base64");
424
+ if (beforeBytes.length === 0 || revision(beforeBytes) !== journal.beforeRevision)
425
+ throw new Error("config rollback journal is invalid");
426
+ const beforeRaw = JSON.parse(beforeBytes.toString("utf8"));
427
+ normalizeConfig(beforeRaw, root);
428
+ validateApplyState(journal.beforeApplyState);
429
+ if (journal.migration !== undefined) {
430
+ validatePiProfileMigrationState(journal.migration);
431
+ assertMigrationTarget(root, journal.targetAgentId, journal.migration);
432
+ }
433
+ const current = readConfigFile(path.join(root, "config.json"), root);
434
+ const currentRevision = revision(current.bytes);
435
+ if (currentRevision === journal.expectedAfterRevision) {
436
+ if (journal.migration)
437
+ rollbackPiProfileMigration(journal.migration);
438
+ atomicWriteBytes(path.join(root, "config.json"), beforeBytes);
439
+ restoreTargetApplyState(root, journal.targetAgentId, journal.beforeRevision, journal.beforeApplyState);
440
+ }
441
+ else if (currentRevision === journal.beforeRevision) {
442
+ // The config replacement may have reached disk before apply-state restoration.
443
+ // Finish that phase before deleting the journal so recovery is complete.
444
+ restoreTargetApplyState(root, journal.targetAgentId, journal.beforeRevision, journal.beforeApplyState);
445
+ }
446
+ else {
447
+ throw new Error("config rollback journal conflicts with the current configuration");
448
+ }
449
+ fs.unlinkSync(file);
450
+ fsyncDirectoryOf(file);
451
+ }
452
+ function recoverMigrationJournal(root) {
453
+ const file = migrationJournalFile(root);
454
+ const bytes = readPrivateFile(file, root, PROFILE_MIGRATION_ROLLBACK_LIMIT_BYTES, "Pi profile migration journal");
455
+ if (bytes === null)
456
+ return;
457
+ let journal;
458
+ try {
459
+ journal = JSON.parse(bytes.toString("utf8"));
460
+ }
461
+ catch {
462
+ throw new Error("Pi profile migration journal is invalid");
463
+ }
464
+ if (journal.version !== 1 || journal.phase !== "forward" || typeof journal.targetAgentId !== "string"
465
+ || typeof journal.beforeRevision !== "string" || typeof journal.expectedAfterRevision !== "string" || !journal.migration) {
466
+ throw new Error("Pi profile migration journal is invalid");
467
+ }
468
+ validatePiProfileMigrationState(journal.migration);
469
+ assertMigrationTarget(root, journal.targetAgentId, journal.migration);
470
+ const current = readConfigFile(path.join(root, "config.json"), root);
471
+ const currentRevision = revision(current.bytes);
472
+ if (currentRevision === journal.beforeRevision) {
473
+ // Forward import did not publish config. The migration helper restores only
474
+ // files still matching its expected post-import hashes.
475
+ try {
476
+ rollbackPiProfileMigration(journal.migration);
477
+ }
478
+ catch (error) {
479
+ throw new Error(`Pi profile migration recovery refused: ${error instanceof Error ? error.message : String(error)}`);
480
+ }
481
+ }
482
+ else if (currentRevision === journal.expectedAfterRevision) {
483
+ // Config won the race; verify the provider target before finalizing.
484
+ const migration = journal.migration;
485
+ assertPiProfileMigrationAfterState(migration);
486
+ }
487
+ else
488
+ throw new Error("Pi profile migration journal conflicts with the current configuration");
489
+ releasePiProfileMigrationLock(journal.migration);
490
+ fs.unlinkSync(file);
491
+ fsyncDirectoryOf(file);
492
+ }
352
493
  export function configApplyState(env, config) {
353
494
  let state;
354
495
  try {
@@ -425,12 +566,21 @@ function applyMutation(config, mutation) {
425
566
  agent.noMentionChats = Object.entries(policies).filter(([, value]) => value === "free").map(([chatId]) => chatId);
426
567
  return { scope: "chat", agentId: mutation.agentId, runtimeChange: false, affectedAgentIds: [mutation.agentId] };
427
568
  }
428
- if (mutation.kind === "set-agent-runtime") {
569
+ if (mutation.kind === "set-agent-pi-distribution") {
570
+ if (agent.runtime !== "pi")
571
+ throw new Error(`Agent ${mutation.agentId} 不是 Pi runtime`);
572
+ if (mutation.distribution !== "builtin" && mutation.distribution !== "external")
573
+ throw new Error("Pi distribution 只允许 builtin/external");
574
+ agent.piDistribution = mutation.distribution;
575
+ }
576
+ else if (mutation.kind === "set-agent-runtime") {
429
577
  if (!loadRuntimeModels()[mutation.runtime])
430
578
  throw new Error(`未知 runtime:${mutation.runtime}`);
431
579
  assertModel(mutation.runtime, mutation.model || "default");
432
580
  agent.runtime = mutation.runtime;
433
581
  agent.model = mutation.model || "default";
582
+ if (mutation.runtime !== "pi")
583
+ delete agent.piDistribution;
434
584
  delete agent.effort;
435
585
  }
436
586
  else if (mutation.kind === "set-agent-model") {
@@ -549,8 +699,52 @@ function reclaimStaleLock(file, snapshot, owner) {
549
699
  }
550
700
  }
551
701
  }
702
+ function isKnownSystemSymlink(current) {
703
+ return current === "/tmp" || current === "/var" || current === "/private/tmp" || current === "/private/var"
704
+ || current === "/var/folders" || /^\/var\/folders\/[^/]+$/.test(current)
705
+ || current === "/private/var/folders" || /^\/private\/var\/folders\/[^/]+$/.test(current);
706
+ }
707
+ function assertNoSymlinkAncestors(target) {
708
+ const resolved = path.resolve(target);
709
+ const root = path.parse(resolved).root;
710
+ let current = root;
711
+ for (const part of path.relative(root, resolved).split(path.sep).filter(Boolean)) {
712
+ current = path.join(current, part);
713
+ let stat;
714
+ try {
715
+ stat = fs.lstatSync(current);
716
+ }
717
+ catch (error) {
718
+ if (error.code === "ENOENT")
719
+ return;
720
+ throw error;
721
+ }
722
+ if (stat.isSymbolicLink() && !isKnownSystemSymlink(current))
723
+ throw new Error("config path contains a symlink ancestor");
724
+ if (!stat.isDirectory() && !stat.isSymbolicLink())
725
+ throw new Error("config path ancestor is not a directory");
726
+ }
727
+ }
728
+ function assertConfigRoot(root) {
729
+ const stat = fs.lstatSync(root);
730
+ if (!stat.isDirectory() || stat.isSymbolicLink())
731
+ throw new Error("config root is unsafe");
732
+ if (process.platform !== "win32" && (stat.mode & 0o777) !== 0o700)
733
+ throw new Error("config root must be 0700");
734
+ if (typeof process.getuid === "function" && stat.uid !== process.getuid())
735
+ throw new Error("config root owner is unsafe");
736
+ }
552
737
  function withConfigLock(layout, action) {
738
+ assertNoSymlinkAncestors(layout.root);
553
739
  fs.mkdirSync(layout.root, { recursive: true, mode: 0o700 });
740
+ const rootStat = fs.lstatSync(layout.root);
741
+ if (!rootStat.isDirectory() || rootStat.isSymbolicLink())
742
+ throw new Error("config root is unsafe");
743
+ if (typeof process.getuid === "function" && rootStat.uid !== process.getuid())
744
+ throw new Error("config root owner is unsafe");
745
+ if (process.platform !== "win32" && (rootStat.mode & 0o777) !== 0o700)
746
+ fs.chmodSync(layout.root, 0o700);
747
+ assertConfigRoot(layout.root);
554
748
  const lock = `${layout.configFile}.lock`;
555
749
  const owner = currentLockRecord();
556
750
  let acquired = false;
@@ -564,6 +758,7 @@ function withConfigLock(layout, action) {
564
758
  finally {
565
759
  fs.closeSync(fd);
566
760
  }
761
+ fsyncDirectoryOf(lock);
567
762
  acquired = true;
568
763
  break;
569
764
  }
@@ -579,6 +774,8 @@ function withConfigLock(layout, action) {
579
774
  if (!acquired)
580
775
  throw new Error("配置正被其他进程修改,请稍后重试");
581
776
  try {
777
+ recoverMigrationJournal(layout.root);
778
+ recoverRollbackJournal(layout.root);
582
779
  return action();
583
780
  }
584
781
  finally {
@@ -586,15 +783,113 @@ function withConfigLock(layout, action) {
586
783
  if (current?.record?.nonce === owner.nonce && current.record.pid === owner.pid) {
587
784
  try {
588
785
  fs.unlinkSync(lock);
786
+ fsyncDirectoryOf(lock);
589
787
  }
590
788
  catch { /* best effort */ }
591
789
  }
592
790
  }
593
791
  }
594
- function atomicWriteConfig(file, value) {
595
- const bytes = Buffer.from(`${JSON.stringify(value, null, 2)}\n`, "utf8");
596
- if (bytes.length > CONFIG_LIMIT_BYTES)
597
- throw new Error(`配置文件超过 ${CONFIG_LIMIT_BYTES} bytes`);
792
+ const CONFIG_ROLLBACK_JOURNAL_FILE = ".config-rollback-journal.json";
793
+ function assertSnapshotPath(file, root, forbidden = []) {
794
+ const resolvedRoot = path.resolve(root);
795
+ const resolvedFile = path.resolve(file);
796
+ if (forbidden.some((candidate) => resolvedFile === path.resolve(candidate)))
797
+ throw new Error("config snapshot path is reserved");
798
+ assertNoSymlinkAncestors(resolvedRoot);
799
+ assertNoSymlinkAncestors(path.dirname(resolvedFile));
800
+ const rootStat = fs.lstatSync(resolvedRoot);
801
+ if (!rootStat.isDirectory() || rootStat.isSymbolicLink())
802
+ throw new Error("config root is unsafe");
803
+ if (process.platform !== "win32" && (rootStat.mode & 0o777) !== 0o700)
804
+ throw new Error("config root must be 0700");
805
+ if (typeof process.getuid === "function" && rootStat.uid !== process.getuid())
806
+ throw new Error("config root owner is unsafe");
807
+ const parent = path.dirname(resolvedFile);
808
+ const rootReal = fs.realpathSync(resolvedRoot);
809
+ const parentReal = fs.realpathSync(parent);
810
+ const relative = path.relative(rootReal, parentReal);
811
+ if (relative.startsWith(`..${path.sep}`) || relative === ".." || path.isAbsolute(relative)) {
812
+ throw new Error("config snapshot parent must be inside the canonical config root");
813
+ }
814
+ const parentStat = fs.lstatSync(parent);
815
+ if (!parentStat.isDirectory() || parentStat.isSymbolicLink())
816
+ throw new Error("config snapshot parent is unsafe");
817
+ if (process.platform !== "win32" && (parentStat.mode & 0o777) !== 0o700)
818
+ throw new Error("config snapshot parent must be 0700");
819
+ if (typeof process.getuid === "function" && parentStat.uid !== process.getuid())
820
+ throw new Error("config snapshot parent owner is unsafe");
821
+ try {
822
+ const stat = fs.lstatSync(resolvedFile);
823
+ if (stat.isSymbolicLink() || !stat.isFile() || stat.nlink !== 1)
824
+ throw new Error("config snapshot path is unsafe");
825
+ if (process.platform !== "win32" && (stat.mode & 0o777) !== 0o600)
826
+ throw new Error("config snapshot must be 0600");
827
+ if (typeof process.getuid === "function" && stat.uid !== process.getuid())
828
+ throw new Error("config snapshot owner is unsafe");
829
+ }
830
+ catch (error) {
831
+ if (error.code !== "ENOENT")
832
+ throw error;
833
+ }
834
+ }
835
+ function writeConfigSnapshot(file, root, snapshot) {
836
+ assertSnapshotPath(file, root, [path.join(root, CONFIG_ROLLBACK_JOURNAL_FILE), path.join(root, "config.json"), path.join(root, "config-apply-state.json")]);
837
+ const bytes = Buffer.from(`${JSON.stringify(snapshot, null, 2)}\n`, "utf8");
838
+ if (bytes.length > PROFILE_MIGRATION_ROLLBACK_LIMIT_BYTES)
839
+ throw new Error("config snapshot is too large");
840
+ const temporary = `${file}.${process.pid}.${crypto.randomUUID()}.tmp`;
841
+ const fd = fs.openSync(temporary, "wx", 0o600);
842
+ try {
843
+ fs.writeFileSync(fd, bytes);
844
+ fs.fsyncSync(fd);
845
+ }
846
+ finally {
847
+ fs.closeSync(fd);
848
+ }
849
+ try {
850
+ fs.renameSync(temporary, file);
851
+ fs.chmodSync(file, 0o600);
852
+ fsyncDirectoryOf(file);
853
+ }
854
+ catch (error) {
855
+ try {
856
+ fs.unlinkSync(temporary);
857
+ }
858
+ catch { /* best effort */ }
859
+ throw error;
860
+ }
861
+ }
862
+ function readConfigSnapshot(file, root) {
863
+ assertSnapshotPath(file, root, [path.join(root, CONFIG_ROLLBACK_JOURNAL_FILE), path.join(root, "config.json"), path.join(root, "config-apply-state.json")]);
864
+ const parsed = JSON.parse(readBoundedPrivateFile(file, PROFILE_MIGRATION_ROLLBACK_LIMIT_BYTES, "config snapshot").toString("utf8"));
865
+ if (parsed.version !== 2 || typeof parsed.targetAgentId !== "string" || !APP_ID.test(parsed.targetAgentId)
866
+ || typeof parsed.beforeRevision !== "string" || typeof parsed.afterRevision !== "string"
867
+ || typeof parsed.afterSignature !== "string" || !parsed.beforeConfig || typeof parsed.beforeConfigBytes !== "string"
868
+ || !isPlainObject(parsed.beforeApplyState)) {
869
+ throw new Error("config snapshot is invalid");
870
+ }
871
+ const beforeBytes = Buffer.from(parsed.beforeConfigBytes, "base64");
872
+ if (beforeBytes.length === 0 || revision(beforeBytes) !== parsed.beforeRevision)
873
+ throw new Error("config snapshot before revision is invalid");
874
+ let beforeRaw;
875
+ try {
876
+ beforeRaw = JSON.parse(beforeBytes.toString("utf8"));
877
+ }
878
+ catch {
879
+ throw new Error("config snapshot before config is invalid");
880
+ }
881
+ if (JSON.stringify(beforeRaw) !== JSON.stringify(parsed.beforeConfig))
882
+ throw new Error("config snapshot before config does not match bytes");
883
+ validateApplyState(parsed.beforeApplyState);
884
+ if (parsed.migration !== undefined) {
885
+ validatePiProfileMigrationState(parsed.migration);
886
+ assertMigrationTarget(root, parsed.targetAgentId, parsed.migration);
887
+ }
888
+ return parsed;
889
+ }
890
+ function atomicWriteBytes(file, bytes, limit = CONFIG_LIMIT_BYTES) {
891
+ if (bytes.length > limit)
892
+ throw new Error(`${limit === CONFIG_LIMIT_BYTES ? "配置文件" : "配置事务文件"}超过 ${limit} bytes`);
598
893
  const temporary = `${file}.${process.pid}.${crypto.randomUUID()}.tmp`;
599
894
  const fd = fs.openSync(temporary, "wx", 0o600);
600
895
  try {
@@ -618,7 +913,13 @@ function atomicWriteConfig(file, value) {
618
913
  }
619
914
  return bytes;
620
915
  }
621
- export function mutateConfig(env, mutation, authority) {
916
+ function atomicWriteConfig(file, value, limit = CONFIG_LIMIT_BYTES) {
917
+ const bytes = Buffer.from(`${JSON.stringify(value, null, 2)}\n`, "utf8");
918
+ if (bytes.length > limit)
919
+ throw new Error(`${limit === CONFIG_LIMIT_BYTES ? "配置文件" : "配置事务文件"}超过 ${limit} bytes`);
920
+ return atomicWriteBytes(file, bytes, limit);
921
+ }
922
+ export function mutateConfig(env, mutation, authority, options = {}) {
622
923
  assertAuthority(authority, mutation);
623
924
  const layout = TargetRootLayout.fromConfigDir(resolveConfigDir(env));
624
925
  return withConfigLock(layout, () => {
@@ -627,11 +928,43 @@ export function mutateConfig(env, mutation, authority) {
627
928
  throw new Error(`没找到配置 ${layout.configFile},先跑 larkin setup`);
628
929
  const config = normalizeConfig(current.raw, layout.root);
629
930
  const priorSignatures = Object.fromEntries(Object.keys(config.agents).map((agentId) => [agentId, agentSignature(config, agentId)]));
931
+ if (options.importExternalProfile && (mutation.kind !== "set-agent-pi-distribution" || mutation.distribution !== "builtin")) {
932
+ throw new Error("external Pi profile import is only valid for builtin Pi distribution");
933
+ }
934
+ const migrationPlan = options.importExternalProfile && mutation.kind === "set-agent-pi-distribution"
935
+ ? preparePiProfileMigration(env, layout.root, mutation.agentId, "builtin")
936
+ : undefined;
630
937
  const changed = applyMutation(config, mutation);
631
938
  const stored = toStored(config);
632
939
  normalizeConfig(stored, layout.root);
633
- const bytes = atomicWriteConfig(layout.configFile, stored);
940
+ const bytes = Buffer.from(`${JSON.stringify(stored, null, 2)}\n`, "utf8");
634
941
  const nextRevision = revision(bytes);
942
+ const beforeApplyState = options.snapshotFile
943
+ ? readApplyFile(layout.root)
944
+ : (() => { try {
945
+ return readApplyFile(layout.root);
946
+ }
947
+ catch {
948
+ return { version: 1, persistedRevision: "unknown", agents: {} };
949
+ } })();
950
+ if (options.snapshotFile) {
951
+ if (changed.affectedAgentIds.length !== 1 || !changed.agentId)
952
+ throw new Error("config snapshot requires exactly one Agent mutation");
953
+ writeConfigSnapshot(options.snapshotFile, layout.root, {
954
+ version: 2, targetAgentId: changed.agentId, beforeRevision: revision(current.bytes), afterRevision: nextRevision,
955
+ afterSignature: agentSignature(config, changed.agentId), beforeConfig: current.raw,
956
+ beforeConfigBytes: current.bytes.toString("base64"), beforeApplyState,
957
+ ...(migrationPlan ? { migration: migrationPlan.state } : {}),
958
+ });
959
+ }
960
+ if (migrationPlan) {
961
+ atomicWriteConfig(migrationJournalFile(layout.root), {
962
+ version: 1, phase: "forward", targetAgentId: changed.agentId, beforeRevision: revision(current.bytes),
963
+ expectedAfterRevision: nextRevision, migration: migrationPlan.state,
964
+ }, PROFILE_MIGRATION_ROLLBACK_LIMIT_BYTES);
965
+ applyPiProfileMigration(migrationPlan);
966
+ }
967
+ atomicWriteConfig(layout.configFile, stored);
635
968
  let fullyApplied = changed.affectedAgentIds.length === 0;
636
969
  try {
637
970
  const applyState = readApplyFile(layout.root);
@@ -654,6 +987,17 @@ export function mutateConfig(env, mutation, authority) {
654
987
  catch {
655
988
  fullyApplied = false; /* Config persistence remains authoritative. */
656
989
  }
990
+ if (migrationPlan) {
991
+ releasePiProfileMigrationLock(migrationPlan.state);
992
+ try {
993
+ fs.unlinkSync(migrationJournalFile(layout.root));
994
+ fsyncDirectoryOf(migrationJournalFile(layout.root));
995
+ }
996
+ catch (error) {
997
+ if (error.code !== "ENOENT")
998
+ throw error;
999
+ }
1000
+ }
657
1001
  return {
658
1002
  revision: nextRevision, previousRevision: revision(current.bytes), changedScope: changed.scope,
659
1003
  ...(changed.agentId ? { agentId: changed.agentId } : {}), persisted: true,
@@ -661,6 +1005,34 @@ export function mutateConfig(env, mutation, authority) {
661
1005
  };
662
1006
  });
663
1007
  }
1008
+ export function rollbackConfig(env, snapshotFile) {
1009
+ const layout = TargetRootLayout.fromConfigDir(resolveConfigDir(env));
1010
+ return withConfigLock(layout, () => {
1011
+ const snapshot = readConfigSnapshot(snapshotFile, layout.root);
1012
+ const beforeBytes = Buffer.from(snapshot.beforeConfigBytes, "base64");
1013
+ const beforeRaw = JSON.parse(beforeBytes.toString("utf8"));
1014
+ normalizeConfig(beforeRaw, layout.root);
1015
+ const current = readConfigFile(layout.configFile, layout.root);
1016
+ const currentConfig = current.raw === null ? null : normalizeConfig(current.raw, layout.root);
1017
+ if (!currentConfig || revision(current.bytes) !== snapshot.afterRevision
1018
+ || agentSignature(currentConfig, snapshot.targetAgentId) !== snapshot.afterSignature) {
1019
+ throw new Error("config changed after the snapshot; refusing rollback");
1020
+ }
1021
+ const journal = {
1022
+ version: 1, targetAgentId: snapshot.targetAgentId, expectedAfterRevision: snapshot.afterRevision,
1023
+ beforeRevision: snapshot.beforeRevision, beforeConfigBytes: snapshot.beforeConfigBytes, beforeApplyState: snapshot.beforeApplyState,
1024
+ ...(snapshot.migration ? { migration: snapshot.migration } : {}),
1025
+ };
1026
+ atomicWriteConfig(rollbackJournalFile(layout.root), journal, PROFILE_MIGRATION_ROLLBACK_LIMIT_BYTES);
1027
+ if (snapshot.migration)
1028
+ rollbackPiProfileMigration(snapshot.migration);
1029
+ const bytes = atomicWriteBytes(layout.configFile, beforeBytes);
1030
+ restoreTargetApplyState(layout.root, snapshot.targetAgentId, snapshot.beforeRevision, snapshot.beforeApplyState);
1031
+ fs.unlinkSync(rollbackJournalFile(layout.root));
1032
+ fsyncDirectoryOf(rollbackJournalFile(layout.root));
1033
+ return { revision: revision(bytes), agentId: snapshot.targetAgentId };
1034
+ });
1035
+ }
664
1036
  export function commitSetupConfig(env, expectedRevision, nextStored) {
665
1037
  const layout = TargetRootLayout.fromConfigDir(resolveConfigDir(env));
666
1038
  return withConfigLock(layout, () => {
@@ -675,7 +1047,7 @@ export function commitSetupConfig(env, expectedRevision, nextStored) {
675
1047
  export function safeConfigView(config, onlyAgentId, chatId, applyState) {
676
1048
  const entries = onlyAgentId ? [[onlyAgentId, config.agents[onlyAgentId]]] : Object.entries(config.agents);
677
1049
  const agents = entries.filter((entry) => Boolean(entry[1])).map(([agentId, agent]) => ({
678
- agentId, runtime: agent.runtime, model: agent.model, effort: agent.effort ?? null,
1050
+ agentId, runtime: agent.runtime, model: agent.model, piDistribution: agent.piDistribution ?? (agent.runtime === "pi" ? "external" : null), effort: agent.effort ?? null,
679
1051
  mention: {
680
1052
  override: agent.mentionPolicy ?? "inherit",
681
1053
  effective: agent.mentionPolicy ?? config.mentionPolicy,