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.
- package/README.md +5 -1
- package/artifacts/release/THIRD_PARTY_NOTICES.txt +218 -228
- package/assets/readme/larkin-github-cover.png +0 -0
- package/dist/agent/agent-state-store.cjs +1 -2
- package/dist/agent/agent-state-store.mjs +1 -2
- package/dist/app/agent-config.mjs +73 -6
- package/dist/app/agent-readiness.cjs +22 -2
- package/dist/app/agent-readiness.mjs +20 -2
- package/dist/app/binary-entry.mjs +6 -0
- package/dist/app/cli.mjs +4 -0
- package/dist/app/run.mjs +8 -0
- package/dist/app/runtime-process.mjs +13 -1
- package/dist/dashboard/dashboard-view-model.mjs +12 -3
- package/dist/feishu/host-shell.cjs +33 -10
- package/dist/feishu/host-shell.mjs +34 -11
- package/dist/platform/config.cjs +406 -33
- package/dist/platform/config.mjs +405 -33
- package/dist/platform/process-boundary-trace.cjs +108 -0
- package/dist/platform/process-boundary-trace.mjs +102 -0
- package/dist/runtime/pi-artifact-provenance.cjs +147 -0
- package/dist/runtime/pi-artifact-provenance.mjs +139 -0
- package/dist/runtime/pi-compaction-recovery.cjs +120 -40
- package/dist/runtime/pi-compaction-recovery.mjs +117 -40
- package/dist/runtime/pi-model-catalog.cjs +2 -2
- package/dist/runtime/pi-model-catalog.mjs +2 -2
- package/dist/runtime/pi-profile-migration.cjs +556 -0
- package/dist/runtime/pi-profile-migration.mjs +515 -0
- package/dist/runtime/pi-provider-config.cjs +1 -1
- package/dist/runtime/pi-provider-config.mjs +1 -1
- package/dist/runtime/runtime-adapters.mjs +97 -11
- package/dist/runtime/runtime-host.cjs +162 -39
- package/dist/runtime/runtime-host.mjs +163 -40
- package/dist/runtime/runtime-readiness.cjs +6 -4
- package/dist/runtime/runtime-readiness.mjs +6 -4
- package/package.json +3 -3
package/dist/platform/config.cjs
CHANGED
|
@@ -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
|
|
274
|
+
function readBoundedPrivateFile(file, limit, label) {
|
|
272
275
|
let fd = null;
|
|
273
276
|
try {
|
|
274
|
-
const
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
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
|
|
280
|
-
assertPrivateConfigMetadata({ regularFile:
|
|
281
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
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-
|
|
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
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
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
|
|
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 =
|
|
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,
|