larkin 0.4.2 → 0.4.5
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 +34 -10
- package/dist/feishu/host-shell.mjs +35 -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.mjs
CHANGED
|
@@ -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
|
|
216
|
+
function readBoundedPrivateFile(file, limit, label) {
|
|
215
217
|
let fd = null;
|
|
216
218
|
try {
|
|
217
|
-
const
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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
|
|
223
|
-
assertPrivateConfigMetadata({ regularFile:
|
|
224
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
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-
|
|
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
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
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
|
-
|
|
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 =
|
|
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,
|