larkin 0.5.1 → 0.5.2

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/CHANGELOG.md CHANGED
@@ -1,5 +1,9 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.2
4
+
5
+ External pi now runs with the user's own Pi home; compaction settings move to the Agent workspace's `.pi/settings.json`; 0.5.0/0.5.1 started external pi with an empty agent dir and saw no logins. Stock pi 0.84.x does not emit `get_state.compactionCapabilities` (that handshake only existed for the bundled build); Larkin accepts the absence and keeps native compaction via the workspace settings file, while a present handshake must still match exactly.
6
+
3
7
  ## 0.5.1
4
8
 
5
9
  External pi is accepted at 0.84.2 or newer instead of exactly 0.84.2; 0.5.0 refused newer pi and left every migrated Agent not ready.
@@ -200,7 +200,7 @@ export function createPiModelDirectoryResolver(options = {}) {
200
200
  }
201
201
  },
202
202
  async resolve(input) {
203
- const key = [input.agentId, input.cwd, input.agentDir ?? "", input.command ?? "", ...(input.commandArgs ?? [])].join("\u0000");
203
+ const key = [input.agentId, input.cwd, input.command ?? "", ...(input.commandArgs ?? [])].join("\u0000");
204
204
  const started = generationOf(input.agentId);
205
205
  pruneCache(cache, (entry) => now() >= entry.expiresAt, 64);
206
206
  pruneCache(failures, (entry) => now() >= entry.expiresAt, 64);
@@ -215,7 +215,6 @@ export function createPiModelDirectoryResolver(options = {}) {
215
215
  try {
216
216
  const catalog = await discover({
217
217
  cwd: input.cwd,
218
- ...(input.agentDir ? { agentDir: input.agentDir } : {}),
219
218
  ...(input.command ? { command: input.command } : {}),
220
219
  ...(input.commandArgs ? { commandArgs: input.commandArgs } : {}),
221
220
  });
@@ -424,7 +423,7 @@ export function createDashboardConfigController({ csrfCapability, env = process.
424
423
  const agent = config.agents[agentId];
425
424
  if (!agent)
426
425
  throw new Error("unknown agent");
427
- // 外部 `pi --mode rpc` 使用用户自己的 Pi home,不再注入 Larkin 持有的 agentDir。
426
+ // 外部 `pi --mode rpc` 使用用户自己的 Pi home;catalog 探测不改写子进程 agent 目录。
428
427
  return await piModelDirectoryResolver.resolve({
429
428
  agentId,
430
429
  cwd: agent.workspaceDir,
@@ -9,6 +9,7 @@ exports.isPiNativeCompactionRequired = isPiNativeCompactionRequired;
9
9
  exports.assertEffectivePiCompactionSettings = assertEffectivePiCompactionSettings;
10
10
  exports.hasProjectPiCompactionOverride = hasProjectPiCompactionOverride;
11
11
  exports.ensureOwnedPiAgentDirectory = ensureOwnedPiAgentDirectory;
12
+ exports.projectPiSettingsFile = projectPiSettingsFile;
12
13
  exports.ownedPiSettings = ownedPiSettings;
13
14
  exports.prepareOwnedPiDirectory = prepareOwnedPiDirectory;
14
15
  exports.mergeOwnedPiSettings = mergeOwnedPiSettings;
@@ -17,7 +18,6 @@ exports.readOwnedPiSettings = readOwnedPiSettings;
17
18
  exports.olderThanMinimumPiVersionMessage = olderThanMinimumPiVersionMessage;
18
19
  exports.parsePiExecutableVersion = parsePiExecutableVersion;
19
20
  exports.verifyPiCapabilities = verifyPiCapabilities;
20
- exports.prepareOwnedPiEnvironment = prepareOwnedPiEnvironment;
21
21
  exports.readProjectPiSettings = readProjectPiSettings;
22
22
  exports.assertNoProjectPiCompactionOverride = assertNoProjectPiCompactionOverride;
23
23
  exports.currentUserLabel = currentUserLabel;
@@ -25,7 +25,7 @@ const node_crypto_1 = __importDefault(require("node:crypto"));
25
25
  const node_fs_1 = __importDefault(require("node:fs"));
26
26
  const node_os_1 = __importDefault(require("node:os"));
27
27
  const node_path_1 = __importDefault(require("node:path"));
28
- /** External `pi` must be this version or newer; handshake still guards protocol compatibility. */
28
+ /** External `pi` must be this version or newer. Stock 0.84.x does not emit compactionCapabilities. */
29
29
  exports.MINIMUM_PI_VERSION = "0.84.2";
30
30
  /** Fallback values used while importing a Pi profile, before Pi reports its model. */
31
31
  exports.PI_CONTEXT_WINDOW = 272_000;
@@ -174,15 +174,16 @@ function ensureOwnedPiAgentDirectory(root, agentId) {
174
174
  ensureDirectory(directory);
175
175
  return directory;
176
176
  }
177
- function ownedPiSettings(directory) {
178
- return node_path_1.default.join(directory, "settings.json");
177
+ /** Larkin-owned project settings that Pi deep-merges over the user's global agent dir. */
178
+ function projectPiSettingsFile(workspaceDir) {
179
+ return node_path_1.default.join(workspaceDir, ".pi", "settings.json");
179
180
  }
180
- function prepareOwnedPiDirectory(directory, compaction) {
181
- ensureDirectoryChain(node_path_1.default.dirname(directory));
182
- ensureDirectory(node_path_1.default.dirname(directory));
183
- ensureDirectory(directory);
184
- writeOwnedPiSettings(directory, compaction);
185
- return directory;
181
+ function ownedPiSettings(workspaceDir) {
182
+ return projectPiSettingsFile(workspaceDir);
183
+ }
184
+ function prepareOwnedPiDirectory(workspaceDir, compaction) {
185
+ writeOwnedPiSettings(workspaceDir, compaction);
186
+ return workspaceDir;
186
187
  }
187
188
  function mergeOwnedPiSettings(value, compaction = {
188
189
  reserveTokens: exports.COMPACTION_RESERVE_TOKENS,
@@ -202,10 +203,11 @@ function mergeOwnedPiSettings(value, compaction = {
202
203
  reserveTokens: compaction.reserveTokens, keepRecentTokens: compaction.keepRecentTokens,
203
204
  } };
204
205
  }
205
- function writeOwnedPiSettings(directory, compaction) {
206
- ensureDirectoryChain(node_path_1.default.dirname(directory));
207
- ensureDirectory(directory);
208
- const file = ownedPiSettings(directory);
206
+ function writeOwnedPiSettings(workspaceDir, compaction) {
207
+ const projectDir = node_path_1.default.join(workspaceDir, ".pi");
208
+ ensureDirectoryChain(node_path_1.default.resolve(workspaceDir));
209
+ ensureDirectory(projectDir);
210
+ const file = projectPiSettingsFile(workspaceDir);
209
211
  try {
210
212
  const existing = node_fs_1.default.lstatSync(file);
211
213
  if (existing.isSymbolicLink() || !existing.isFile())
@@ -223,7 +225,7 @@ function writeOwnedPiSettings(directory, compaction) {
223
225
  if (error.code !== "ENOENT")
224
226
  throw new Error("Pi owned settings are invalid");
225
227
  }
226
- const temporary = node_path_1.default.join(directory, `.${node_path_1.default.basename(file)}.${process.pid}.${node_crypto_1.default.randomUUID()}.tmp`);
228
+ const temporary = node_path_1.default.join(projectDir, `.${node_path_1.default.basename(file)}.${process.pid}.${node_crypto_1.default.randomUUID()}.tmp`);
227
229
  try {
228
230
  node_fs_1.default.writeFileSync(temporary, `${JSON.stringify(mergeOwnedPiSettings(value, compaction), null, 2)}\n`, { flag: "wx", mode: 0o600 });
229
231
  node_fs_1.default.renameSync(temporary, file);
@@ -236,10 +238,10 @@ function writeOwnedPiSettings(directory, compaction) {
236
238
  catch { /* renamed or absent */ }
237
239
  }
238
240
  }
239
- function readOwnedPiSettings(directory) {
240
- ensureDirectoryChain(node_path_1.default.dirname(directory));
241
- assertOwnedDirectory(directory);
242
- const file = ownedPiSettings(directory);
241
+ function readOwnedPiSettings(workspaceDir) {
242
+ const projectDir = node_path_1.default.join(workspaceDir, ".pi");
243
+ assertOwnedDirectory(projectDir);
244
+ const file = projectPiSettingsFile(workspaceDir);
243
245
  const stat = node_fs_1.default.lstatSync(file);
244
246
  if (stat.isSymbolicLink() || !stat.isFile())
245
247
  throw new Error("Pi owned settings must be a regular file");
@@ -299,12 +301,30 @@ function parsePiExecutableVersion(output) {
299
301
  }
300
302
  return version;
301
303
  }
304
+ function resolveOptionalHandshake(capabilities) {
305
+ if (capabilities.compactionCapabilities !== undefined && capabilities.compactionCapabilities !== null) {
306
+ return capabilities.compactionCapabilities;
307
+ }
308
+ if (capabilities.reserveTokens !== undefined
309
+ || capabilities.keepRecentTokens !== undefined
310
+ || capabilities.events !== undefined) {
311
+ return {
312
+ reserveTokens: capabilities.reserveTokens,
313
+ keepRecentTokens: capabilities.keepRecentTokens,
314
+ events: capabilities.events,
315
+ };
316
+ }
317
+ return undefined;
318
+ }
302
319
  function verifyPiCapabilities(capabilities) {
303
320
  assertSupportedPiVersion(capabilities.version);
304
321
  if (capabilities.trustedProtocol === true) {
305
322
  throw new Error("external Pi cannot use a trusted protocol bypass");
306
323
  }
307
324
  const contextWindow = capabilities.contextWindow ?? capabilities.model?.contextWindow;
325
+ if (typeof contextWindow !== "number" || !Number.isFinite(contextWindow) || contextWindow <= 0) {
326
+ throw new Error("Pi capability context window is invalid");
327
+ }
308
328
  const expected = (() => {
309
329
  try {
310
330
  return calculatePiCompactionSettings(contextWindow);
@@ -318,14 +338,18 @@ function verifyPiCapabilities(capabilities) {
318
338
  }
319
339
  if (capabilities.compactRpc !== true)
320
340
  throw new Error("Pi compact RPC capability is missing");
321
- if (capabilities.reserveTokens !== expected.reserveTokens
322
- || capabilities.keepRecentTokens !== expected.keepRecentTokens) {
341
+ const handshake = resolveOptionalHandshake(capabilities);
342
+ if (handshake === undefined)
343
+ return "larkin-settings-only";
344
+ if (handshake.reserveTokens !== expected.reserveTokens
345
+ || handshake.keepRecentTokens !== expected.keepRecentTokens) {
323
346
  throw new Error(`Pi external effective compaction reserve/keep settings are unproven for context window ${contextWindow}`);
324
347
  }
325
- const events = new Set(capabilities.events || []);
348
+ const events = new Set(handshake.events || []);
326
349
  for (const event of REQUIRED_PI_EVENTS)
327
350
  if (!events.has(event))
328
351
  throw new Error(`Pi capability event is unproven: ${event}`);
352
+ return "handshake-reported";
329
353
  }
330
354
  function stateFile(root) {
331
355
  return node_path_1.default.join(root, exports.PI_COMPACTION_RECOVERY_FILE);
@@ -703,11 +727,6 @@ class PiCompactionRecoveryMachine {
703
727
  }
704
728
  }
705
729
  exports.PiCompactionRecoveryMachine = PiCompactionRecoveryMachine;
706
- function prepareOwnedPiEnvironment(input) {
707
- const directory = ensureOwnedPiAgentDirectory(input.root, input.agentId);
708
- writeOwnedPiSettings(directory);
709
- return { ...input.env, PI_CODING_AGENT_DIR: directory };
710
- }
711
730
  function readProjectPiSettings(workspaceDir) {
712
731
  const file = node_path_1.default.join(workspaceDir, ".pi", "settings.json");
713
732
  try {
@@ -2,7 +2,7 @@ import crypto from "node:crypto";
2
2
  import fs from "node:fs";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
- /** External `pi` must be this version or newer; handshake still guards protocol compatibility. */
5
+ /** External `pi` must be this version or newer. Stock 0.84.x does not emit compactionCapabilities. */
6
6
  export const MINIMUM_PI_VERSION = "0.84.2";
7
7
  /** Fallback values used while importing a Pi profile, before Pi reports its model. */
8
8
  export const PI_CONTEXT_WINDOW = 272_000;
@@ -151,15 +151,16 @@ export function ensureOwnedPiAgentDirectory(root, agentId) {
151
151
  ensureDirectory(directory);
152
152
  return directory;
153
153
  }
154
- export function ownedPiSettings(directory) {
155
- return path.join(directory, "settings.json");
154
+ /** Larkin-owned project settings that Pi deep-merges over the user's global agent dir. */
155
+ export function projectPiSettingsFile(workspaceDir) {
156
+ return path.join(workspaceDir, ".pi", "settings.json");
156
157
  }
157
- export function prepareOwnedPiDirectory(directory, compaction) {
158
- ensureDirectoryChain(path.dirname(directory));
159
- ensureDirectory(path.dirname(directory));
160
- ensureDirectory(directory);
161
- writeOwnedPiSettings(directory, compaction);
162
- return directory;
158
+ export function ownedPiSettings(workspaceDir) {
159
+ return projectPiSettingsFile(workspaceDir);
160
+ }
161
+ export function prepareOwnedPiDirectory(workspaceDir, compaction) {
162
+ writeOwnedPiSettings(workspaceDir, compaction);
163
+ return workspaceDir;
163
164
  }
164
165
  export function mergeOwnedPiSettings(value, compaction = {
165
166
  reserveTokens: COMPACTION_RESERVE_TOKENS,
@@ -179,10 +180,11 @@ export function mergeOwnedPiSettings(value, compaction = {
179
180
  reserveTokens: compaction.reserveTokens, keepRecentTokens: compaction.keepRecentTokens,
180
181
  } };
181
182
  }
182
- export function writeOwnedPiSettings(directory, compaction) {
183
- ensureDirectoryChain(path.dirname(directory));
184
- ensureDirectory(directory);
185
- const file = ownedPiSettings(directory);
183
+ export function writeOwnedPiSettings(workspaceDir, compaction) {
184
+ const projectDir = path.join(workspaceDir, ".pi");
185
+ ensureDirectoryChain(path.resolve(workspaceDir));
186
+ ensureDirectory(projectDir);
187
+ const file = projectPiSettingsFile(workspaceDir);
186
188
  try {
187
189
  const existing = fs.lstatSync(file);
188
190
  if (existing.isSymbolicLink() || !existing.isFile())
@@ -200,7 +202,7 @@ export function writeOwnedPiSettings(directory, compaction) {
200
202
  if (error.code !== "ENOENT")
201
203
  throw new Error("Pi owned settings are invalid");
202
204
  }
203
- const temporary = path.join(directory, `.${path.basename(file)}.${process.pid}.${crypto.randomUUID()}.tmp`);
205
+ const temporary = path.join(projectDir, `.${path.basename(file)}.${process.pid}.${crypto.randomUUID()}.tmp`);
204
206
  try {
205
207
  fs.writeFileSync(temporary, `${JSON.stringify(mergeOwnedPiSettings(value, compaction), null, 2)}\n`, { flag: "wx", mode: 0o600 });
206
208
  fs.renameSync(temporary, file);
@@ -213,10 +215,10 @@ export function writeOwnedPiSettings(directory, compaction) {
213
215
  catch { /* renamed or absent */ }
214
216
  }
215
217
  }
216
- export function readOwnedPiSettings(directory) {
217
- ensureDirectoryChain(path.dirname(directory));
218
- assertOwnedDirectory(directory);
219
- const file = ownedPiSettings(directory);
218
+ export function readOwnedPiSettings(workspaceDir) {
219
+ const projectDir = path.join(workspaceDir, ".pi");
220
+ assertOwnedDirectory(projectDir);
221
+ const file = projectPiSettingsFile(workspaceDir);
220
222
  const stat = fs.lstatSync(file);
221
223
  if (stat.isSymbolicLink() || !stat.isFile())
222
224
  throw new Error("Pi owned settings must be a regular file");
@@ -276,12 +278,30 @@ export function parsePiExecutableVersion(output) {
276
278
  }
277
279
  return version;
278
280
  }
281
+ function resolveOptionalHandshake(capabilities) {
282
+ if (capabilities.compactionCapabilities !== undefined && capabilities.compactionCapabilities !== null) {
283
+ return capabilities.compactionCapabilities;
284
+ }
285
+ if (capabilities.reserveTokens !== undefined
286
+ || capabilities.keepRecentTokens !== undefined
287
+ || capabilities.events !== undefined) {
288
+ return {
289
+ reserveTokens: capabilities.reserveTokens,
290
+ keepRecentTokens: capabilities.keepRecentTokens,
291
+ events: capabilities.events,
292
+ };
293
+ }
294
+ return undefined;
295
+ }
279
296
  export function verifyPiCapabilities(capabilities) {
280
297
  assertSupportedPiVersion(capabilities.version);
281
298
  if (capabilities.trustedProtocol === true) {
282
299
  throw new Error("external Pi cannot use a trusted protocol bypass");
283
300
  }
284
301
  const contextWindow = capabilities.contextWindow ?? capabilities.model?.contextWindow;
302
+ if (typeof contextWindow !== "number" || !Number.isFinite(contextWindow) || contextWindow <= 0) {
303
+ throw new Error("Pi capability context window is invalid");
304
+ }
285
305
  const expected = (() => {
286
306
  try {
287
307
  return calculatePiCompactionSettings(contextWindow);
@@ -295,14 +315,18 @@ export function verifyPiCapabilities(capabilities) {
295
315
  }
296
316
  if (capabilities.compactRpc !== true)
297
317
  throw new Error("Pi compact RPC capability is missing");
298
- if (capabilities.reserveTokens !== expected.reserveTokens
299
- || capabilities.keepRecentTokens !== expected.keepRecentTokens) {
318
+ const handshake = resolveOptionalHandshake(capabilities);
319
+ if (handshake === undefined)
320
+ return "larkin-settings-only";
321
+ if (handshake.reserveTokens !== expected.reserveTokens
322
+ || handshake.keepRecentTokens !== expected.keepRecentTokens) {
300
323
  throw new Error(`Pi external effective compaction reserve/keep settings are unproven for context window ${contextWindow}`);
301
324
  }
302
- const events = new Set(capabilities.events || []);
325
+ const events = new Set(handshake.events || []);
303
326
  for (const event of REQUIRED_PI_EVENTS)
304
327
  if (!events.has(event))
305
328
  throw new Error(`Pi capability event is unproven: ${event}`);
329
+ return "handshake-reported";
306
330
  }
307
331
  function stateFile(root) {
308
332
  return path.join(root, PI_COMPACTION_RECOVERY_FILE);
@@ -678,11 +702,6 @@ export class PiCompactionRecoveryMachine {
678
702
  this.change("closed");
679
703
  }
680
704
  }
681
- export function prepareOwnedPiEnvironment(input) {
682
- const directory = ensureOwnedPiAgentDirectory(input.root, input.agentId);
683
- writeOwnedPiSettings(directory);
684
- return { ...input.env, PI_CODING_AGENT_DIR: directory };
685
- }
686
705
  export function readProjectPiSettings(workspaceDir) {
687
706
  const file = path.join(workspaceDir, ".pi", "settings.json");
688
707
  try {
@@ -1,37 +1,4 @@
1
1
  "use strict";
2
- var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
- if (k2 === undefined) k2 = k;
4
- var desc = Object.getOwnPropertyDescriptor(m, k);
5
- if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
- desc = { enumerable: true, get: function() { return m[k]; } };
7
- }
8
- Object.defineProperty(o, k2, desc);
9
- }) : (function(o, m, k, k2) {
10
- if (k2 === undefined) k2 = k;
11
- o[k2] = m[k];
12
- }));
13
- var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
- Object.defineProperty(o, "default", { enumerable: true, value: v });
15
- }) : function(o, v) {
16
- o["default"] = v;
17
- });
18
- var __importStar = (this && this.__importStar) || (function () {
19
- var ownKeys = function(o) {
20
- ownKeys = Object.getOwnPropertyNames || function (o) {
21
- var ar = [];
22
- for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
- return ar;
24
- };
25
- return ownKeys(o);
26
- };
27
- return function (mod) {
28
- if (mod && mod.__esModule) return mod;
29
- var result = {};
30
- if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
- __setModuleDefault(result, mod);
32
- return result;
33
- };
34
- })();
35
2
  Object.defineProperty(exports, "__esModule", { value: true });
36
3
  exports.PI_THINKING_LEVELS = void 0;
37
4
  exports.piModelId = piModelId;
@@ -40,7 +7,6 @@ exports.isPiThinkingLevel = isPiThinkingLevel;
40
7
  exports.findExactPiModel = findExactPiModel;
41
8
  exports.discoverPiModelCatalog = discoverPiModelCatalog;
42
9
  const node_child_process_1 = require("node:child_process");
43
- const path = __importStar(require("node:path"));
44
10
  const pi_rpc_client_js_1 = require("./pi-rpc-client.cjs");
45
11
  const runtime_readiness_js_1 = require("./runtime-readiness.cjs");
46
12
  exports.PI_THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"];
@@ -77,17 +43,15 @@ function piCatalogChildEnv(options) {
77
43
  ...options.env,
78
44
  NO_COLOR: "1",
79
45
  };
80
- if (options.agentDir)
81
- env.PI_CODING_AGENT_DIR = path.resolve(options.agentDir);
82
- else
83
- delete env.PI_CODING_AGENT_DIR;
46
+ // External pi 必须看见用户自己的 home;Larkin 从不注入或转发 PI_CODING_AGENT_DIR。
47
+ delete env.PI_CODING_AGENT_DIR;
84
48
  return env;
85
49
  }
86
50
  async function discoverPiModelCatalog(options) {
87
51
  if (!options.spawn) {
88
52
  const command = options.command ?? options.env?.LARKIN_PI_COMMAND ?? process.env.LARKIN_PI_COMMAND ?? "pi";
89
53
  const childEnv = piCatalogChildEnv(options);
90
- const key = `${command}|${(options.commandArgs ?? []).join("\0")}|${childEnv.PI_CODING_AGENT_DIR ?? ""}|${options.agentDir ?? ""}|${childEnv.PI_PACKAGE_DIR ?? ""}|${options.packageDir ?? ""}`;
54
+ const key = `${command}|${(options.commandArgs ?? []).join("\0")}|${childEnv.PI_PACKAGE_DIR ?? ""}|${options.packageDir ?? ""}`;
91
55
  const cached = discoveryCache.get(key);
92
56
  if (cached)
93
57
  return cached;
@@ -1,5 +1,4 @@
1
1
  import { spawn as nodeSpawn } from "node:child_process";
2
- import * as path from "node:path";
3
2
  import { PiRpcClient } from "./pi-rpc-client.mjs";
4
3
  import { classifyRuntimePrerequisite, RuntimePrerequisiteError } from "./runtime-readiness.mjs";
5
4
  export const PI_THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"];
@@ -36,17 +35,15 @@ function piCatalogChildEnv(options) {
36
35
  ...options.env,
37
36
  NO_COLOR: "1",
38
37
  };
39
- if (options.agentDir)
40
- env.PI_CODING_AGENT_DIR = path.resolve(options.agentDir);
41
- else
42
- delete env.PI_CODING_AGENT_DIR;
38
+ // External pi 必须看见用户自己的 home;Larkin 从不注入或转发 PI_CODING_AGENT_DIR。
39
+ delete env.PI_CODING_AGENT_DIR;
43
40
  return env;
44
41
  }
45
42
  export async function discoverPiModelCatalog(options) {
46
43
  if (!options.spawn) {
47
44
  const command = options.command ?? options.env?.LARKIN_PI_COMMAND ?? process.env.LARKIN_PI_COMMAND ?? "pi";
48
45
  const childEnv = piCatalogChildEnv(options);
49
- const key = `${command}|${(options.commandArgs ?? []).join("\0")}|${childEnv.PI_CODING_AGENT_DIR ?? ""}|${options.agentDir ?? ""}|${childEnv.PI_PACKAGE_DIR ?? ""}|${options.packageDir ?? ""}`;
46
+ const key = `${command}|${(options.commandArgs ?? []).join("\0")}|${childEnv.PI_PACKAGE_DIR ?? ""}|${options.packageDir ?? ""}`;
50
47
  const cached = discoveryCache.get(key);
51
48
  if (cached)
52
49
  return cached;
@@ -7,8 +7,9 @@ import { fileURLToPath } from "node:url";
7
7
  *
8
8
  * 分发:构建期把 @tintinweb/pi-subagents bundle 成单文件
9
9
  * `dist/runtime/pi-subagents.bundle.js`(pi-* 包 external),运行时仅向 external
10
- * (用户 pi CLI)通过 `pi --extension/-e` 显式注入,不碰用户 ~/.pi 配置。
11
- * Builtin Pi 直接接收静态 factory,不经过本文件或 Pi 的路径加载器。
10
+ * (用户 pi CLI)通过 `pi --extension/-e` 显式注入。可只读用户 ~/.pi/agent
11
+ * 的非密钥设置/扩展元数据以避免重复注册 FATAL;不得读、复制或写 auth.json,
12
+ * 也不得写入 ~/.pi。
12
13
  *
13
14
  * 版本门槛:pi-subagents peerDependency 要求 @earendil-works/pi-* >= 0.80.0;
14
15
  * external 的 pi 版本低于 0.80 时不注入(降级为无 subagent 能力)。
@@ -184,16 +185,49 @@ export function probeExternalPiVersion(piCommand, env) {
184
185
  * 用户 pi 是否已自行安装 pi-subagents(settings.json packages 或包目录)。
185
186
  * 已装时 Larkin 不再 -e 注入,避免同名工具(Agent/get_subagent_result/steer_subagent)
186
187
  * 重复注册导致 pi 扩展加载 FATAL(pi 对 duplicate tool registration 是硬失败)。
188
+ *
189
+ * Owner 边界:只读非密钥兼容信息(settings.json / package 清单 / 扩展源码标记)。
190
+ * 不得打开或写入用户 Pi home 里的 auth.json,也不得往 ~/.pi 写任何文件。
187
191
  */
188
192
  const BOUNDED_WAIT_CAPABILITY = "larkin-pi-subagents-bounded-wait-v1";
189
193
  const SUPERVISED_COMMAND_CAPABILITY = "larkin-pi-supervised-command-v1";
194
+ function assertRegularFile(file) {
195
+ const stat = fs.lstatSync(file);
196
+ if (stat.isSymbolicLink() || !stat.isFile() || stat.nlink !== 1) {
197
+ throw new Error("user Pi path is not a regular file");
198
+ }
199
+ }
200
+ function readRegularFile(file) {
201
+ assertRegularFile(file);
202
+ return fs.readFileSync(file, "utf8");
203
+ }
204
+ function isRealpathInside(root, candidate) {
205
+ const rootReal = fs.realpathSync(root);
206
+ const candidateReal = fs.realpathSync(candidate);
207
+ const prefix = rootReal.endsWith(path.sep) ? rootReal : `${rootReal}${path.sep}`;
208
+ return candidateReal.startsWith(prefix);
209
+ }
210
+ /** 只读包根内的单链接常规文件;绝对路径、逃逸、符号链接、硬链接一律视为不可验证。 */
211
+ function readContainedPackageEntry(packageRoot, entry) {
212
+ if (path.isAbsolute(entry))
213
+ throw new Error("absolute Pi extension entry");
214
+ const root = path.resolve(packageRoot);
215
+ const resolved = path.resolve(root, entry);
216
+ const lexicalPrefix = root.endsWith(path.sep) ? root : `${root}${path.sep}`;
217
+ if (resolved !== root && !resolved.startsWith(lexicalPrefix))
218
+ throw new Error("Pi extension entry escaped package root");
219
+ assertRegularFile(resolved);
220
+ if (!isRealpathInside(root, resolved))
221
+ throw new Error("Pi extension entry escaped package root");
222
+ return fs.readFileSync(resolved, "utf8");
223
+ }
190
224
  function userPiSubagentsWaitCapability(env) {
191
225
  const agentDir = env.PI_CODING_AGENT_DIR || path.join(env.HOME || process.env.HOME || "", ".pi", "agent");
192
226
  try {
193
227
  let configured = false;
194
228
  const settingsFile = path.join(agentDir, "settings.json");
195
229
  if (fs.existsSync(settingsFile)) {
196
- const settings = JSON.parse(fs.readFileSync(settingsFile, "utf8"));
230
+ const settings = JSON.parse(readRegularFile(settingsFile));
197
231
  const packages = settings?.packages;
198
232
  if (Array.isArray(packages)) {
199
233
  configured = packages.some((entry) => typeof entry === "string" && /pi-subagents/i.test(entry));
@@ -210,7 +244,7 @@ function userPiSubagentsWaitCapability(env) {
210
244
  return "absent";
211
245
  if (!installedRoot)
212
246
  return "unbounded";
213
- const packageManifest = JSON.parse(fs.readFileSync(path.join(installedRoot, "package.json"), "utf8"));
247
+ const packageManifest = JSON.parse(readRegularFile(path.join(installedRoot, "package.json")));
214
248
  const extensions = packageManifest.pi?.extensions;
215
249
  if (!Array.isArray(extensions) || extensions.length === 0
216
250
  || !extensions.every((entry) => typeof entry === "string" && entry.length > 0)) {
@@ -218,20 +252,14 @@ function userPiSubagentsWaitCapability(env) {
218
252
  }
219
253
  // Pi executes the entries declared by the package manifest. Do not trust a
220
254
  // marker in a build artifact when the manifest points Pi at another file.
221
- const capabilityFiles = extensions.map((entry) => path.resolve(installedRoot, entry));
222
- const hasCapability = capabilityFiles.every((file) => {
223
- try {
224
- const source = fs.readFileSync(file, "utf8");
225
- return source.includes(BOUNDED_WAIT_CAPABILITY) && source.includes(SUPERVISED_COMMAND_CAPABILITY);
226
- }
227
- catch {
228
- return false;
229
- }
255
+ const hasCapability = extensions.every((entry) => {
256
+ const source = readContainedPackageEntry(installedRoot, entry);
257
+ return source.includes(BOUNDED_WAIT_CAPABILITY) && source.includes(SUPERVISED_COMMAND_CAPABILITY);
230
258
  });
231
259
  return hasCapability ? "bounded" : "unbounded";
232
260
  }
233
261
  catch {
234
- // Unreadable user extension state is not safe to treat as bounded.
262
+ // Unreadable or escaped user extension state is not safe to treat as bounded.
235
263
  return "unbounded";
236
264
  }
237
265
  }
@@ -5,7 +5,6 @@ import * as path from "node:path";
5
5
  import { createInterface } from "node:readline";
6
6
  import { isPiThinkingLevel } from "./pi-model-catalog.mjs";
7
7
  import { PiRpcClient } from "./pi-rpc-client.mjs";
8
- import { recordPiRuntimeArtifactProvenance } from "./pi-artifact-provenance.mjs";
9
8
  import { traceProcessBoundary } from "../platform/process-boundary-trace.mjs";
10
9
  import { resolvePiSubagentExtensionArg } from "./pi-subagent-injection.mjs";
11
10
  import { extractCanonicalPiSubagentNotification, ledgerStatusFromPiNotificationStatus, } from "./pi-subagents-notification.mjs";
@@ -14,9 +13,12 @@ import { effectivePiStateDir, extractBackgroundPiSubagentDispatch } from "./pi-s
14
13
  import { resolvePiBashTimeoutExtensionArg } from "./pi-bash-timeout-injection.mjs";
15
14
  import { classifyPiMissingCredentialRejection, classifyRuntimePrerequisite, missingProviderCredentialReadiness, probeNativeRuntimeReadiness, providerAuthenticationFailureReadiness, RuntimePrerequisiteError, } from "./runtime-readiness.mjs";
16
15
  import { buildStrictProviderErrorInput, classifyStrictProviderError, INTERNAL_CONTEXT_WINDOW_PROJECTION_REASON, } from "./provider-error-classifier.mjs";
17
- import { assertEffectivePiCompactionSettings, assertNoProjectPiCompactionOverride, MINIMUM_PI_VERSION, calculatePiCompactionSettings, parsePiExecutableVersion, prepareOwnedPiDirectory, readOwnedPiSettings, verifyPiCapabilities, writeOwnedPiSettings, } from "./pi-compaction-recovery.mjs";
18
- function piAgentDirectory(configDir, agentId) {
19
- return path.join(path.resolve(configDir), "providers", "pi", agentId);
16
+ import { assertEffectivePiCompactionSettings, MINIMUM_PI_VERSION, calculatePiCompactionSettings, parsePiExecutableVersion, projectPiSettingsFile, readOwnedPiSettings, verifyPiCapabilities, writeOwnedPiSettings, } from "./pi-compaction-recovery.mjs";
17
+ /** External pi must see the user's own agent dir; Larkin never injects or honors PI_CODING_AGENT_DIR. */
18
+ function stripPiCodingAgentDir(env) {
19
+ const next = { ...env };
20
+ delete next.PI_CODING_AGENT_DIR;
21
+ return next;
20
22
  }
21
23
  class EventSession {
22
24
  listeners = new Set();
@@ -977,19 +979,12 @@ class PiRpcBackend {
977
979
  this.sessionFile = state.sessionFile;
978
980
  if (typeof state.sessionId === "string" && state.sessionId)
979
981
  this.sessionId = state.sessionId;
980
- // Use the live process handshake, not a disk re-read of owned settings.json.
981
- // Disk reserveTokens can drift after startup; that must not reject prompt.
982
- const handshake = state.compactionCapabilities;
983
- if (typeof handshake?.reserveTokens === "number" && typeof handshake?.keepRecentTokens === "number") {
984
- assertEffectivePiCompactionSettings({ contextWindow: state.model?.contextWindow, compaction: {
985
- enabled: state.autoCompactionEnabled,
986
- reserveTokens: handshake.reserveTokens,
987
- keepRecentTokens: handshake.keepRecentTokens,
988
- } });
989
- return;
990
- }
991
- if (state.autoCompactionEnabled !== true)
992
- throw new Error("Pi native compaction must be enabled");
982
+ // Prompt 只信 live get_state:有 handshake 就必须精确匹配,没有则只要求 compaction 仍开启。
983
+ verifyPiCapabilities({
984
+ distribution: "external", version: this.policy.version,
985
+ contextWindow: state.model?.contextWindow, autoCompactionEnabled: state.autoCompactionEnabled,
986
+ compactRpc: true, compactionCapabilities: state.compactionCapabilities,
987
+ });
993
988
  }
994
989
  getState() { return this.client.request("get_state"); }
995
990
  dispose() { return this.client.close(); }
@@ -1017,13 +1012,11 @@ export function resolvePiProcessExtensionArgs(input, resolvers = {}) {
1017
1012
  }
1018
1013
  async function createPiRpcBackend(input, dependencies, spawn, productionSpawn = true) {
1019
1014
  const stateRoot = effectivePiStateDir(input);
1020
- const mergedEnv = { ...globalThis.process.env, ...dependencies.env, ...input.env, NO_COLOR: "1" };
1021
- assertNoProjectPiCompactionOverride(input.workspaceDir);
1022
- const ownedPiDirectory = mergedEnv.LARKIN_CONFIG_DIR
1023
- ? piAgentDirectory(mergedEnv.LARKIN_CONFIG_DIR, input.agentId)
1024
- : path.join(stateRoot, "pi-agent");
1025
- prepareOwnedPiDirectory(ownedPiDirectory);
1026
- traceProcessBoundary(mergedEnv, "pi-rpc:child-env", { configDir: mergedEnv.LARKIN_CONFIG_DIR, agentId: input.agentId, targetDir: ownedPiDirectory, childEnvConfigDir: mergedEnv.LARKIN_CONFIG_DIR || null, childEnvHome: mergedEnv.HOME || null });
1015
+ const mergedEnv = stripPiCodingAgentDir({ ...globalThis.process.env, ...dependencies.env, ...input.env, NO_COLOR: "1" });
1016
+ if (mergedEnv.LARKIN_PI_DISTRIBUTION === "builtin")
1017
+ delete mergedEnv.LARKIN_PI_DISTRIBUTION;
1018
+ const projectSettings = projectPiSettingsFile(input.workspaceDir);
1019
+ traceProcessBoundary(mergedEnv, "pi-rpc:child-env", { configDir: mergedEnv.LARKIN_CONFIG_DIR, agentId: input.agentId, targetDir: projectSettings, childEnvConfigDir: mergedEnv.LARKIN_CONFIG_DIR || null, childEnvHome: mergedEnv.HOME || null });
1027
1020
  const runtimeDir = path.join(stateRoot, "runtime");
1028
1021
  fs.mkdirSync(runtimeDir, { recursive: true, mode: 0o700 });
1029
1022
  const promptFile = path.join(runtimeDir, "pi-standing-prompt.md");
@@ -1033,17 +1026,15 @@ async function createPiRpcBackend(input, dependencies, spawn, productionSpawn =
1033
1026
  const requestedEffort = input.reasoningEffort?.trim() || undefined;
1034
1027
  if (requestedEffort && !isPiThinkingLevel(requestedEffort))
1035
1028
  throw new Error(`Unknown Pi thinking level: ${requestedEffort}`);
1036
- const args = ["--mode", "rpc", "--session-dir", session.sessionDir, "--append-system-prompt", promptFile,
1029
+ // `--approve` 让 RPC 在无 UI 时仍加载工作区 `.pi/settings.json`(否则 settings.json 会触发 trust gate 并被忽略)。
1030
+ const args = ["--mode", "rpc", "--approve", "--session-dir", session.sessionDir, "--append-system-prompt", promptFile,
1037
1031
  ...(session.sessionFile ? ["--session", session.sessionFile] : []),
1038
1032
  ...(requestedModel ? ["--model", requestedModel] : []),
1039
1033
  ...(requestedEffort ? ["--thinking", requestedEffort] : [])];
1040
- if (mergedEnv.LARKIN_PI_DISTRIBUTION === "builtin")
1041
- delete mergedEnv.LARKIN_PI_DISTRIBUTION;
1042
1034
  const command = dependencies.piCommand ?? dependencies.env?.LARKIN_PI_COMMAND ?? process.env.LARKIN_PI_COMMAND ?? "pi";
1043
1035
  const commandPrefix = dependencies.piCommandArgs ?? [];
1044
1036
  const commandArgs = [...commandPrefix, ...args];
1045
- mergedEnv.PI_CODING_AGENT_DIR = ownedPiDirectory;
1046
- const childEnv = { ...mergedEnv };
1037
+ const childEnv = stripPiCodingAgentDir(mergedEnv);
1047
1038
  const reportedVersion = productionSpawn
1048
1039
  ? parsePiExecutableVersion(String(spawnSync(command, [...commandPrefix, "--version"], {
1049
1040
  cwd: input.workspaceDir, env: childEnv, encoding: "utf8", timeout: 5_000,
@@ -1057,21 +1048,16 @@ async function createPiRpcBackend(input, dependencies, spawn, productionSpawn =
1057
1048
  commandArgs.push(...extensionArgs);
1058
1049
  let probedModel = null;
1059
1050
  if (productionSpawn) {
1060
- // Pi reads compaction settings when the process starts. Probe the exact
1061
- // requested model first, then write the matching 15% reserve before the
1062
- // real session starts; no prompt, provider request, package, or extension
1063
- // is loaded by the probe.
1051
+ // 隔离探测用户自己的 Pi home(无 PI_CODING_AGENT_DIR),再把 15% reserve 写到工作区项目设置。
1064
1052
  probedModel = await discoverEffectivePiContextWindow(input, command, commandPrefix, requestedModel, childEnv, spawn, dependencies.piRpcClientOptions);
1065
- writeOwnedPiSettings(ownedPiDirectory, calculatePiCompactionSettings(probedModel.contextWindow));
1053
+ writeOwnedPiSettings(input.workspaceDir, calculatePiCompactionSettings(probedModel.contextWindow));
1066
1054
  }
1067
- const artifactEntriesBeforeSpawn = new Set(fs.readdirSync(ownedPiDirectory));
1068
- const artifactSpawnBoundary = Date.now();
1069
1055
  const child = spawn(command, commandArgs, {
1070
1056
  cwd: input.workspaceDir,
1071
1057
  env: childEnv,
1072
1058
  stdio: ["pipe", "pipe", "pipe"],
1073
1059
  });
1074
- traceProcessBoundary(mergedEnv, "pi-rpc:child-spawned", { configDir: mergedEnv.LARKIN_CONFIG_DIR, agentId: input.agentId, targetDir: ownedPiDirectory, childPid: child.pid ?? null });
1060
+ traceProcessBoundary(mergedEnv, "pi-rpc:child-spawned", { configDir: mergedEnv.LARKIN_CONFIG_DIR, agentId: input.agentId, targetDir: projectSettings, childPid: child.pid ?? null });
1075
1061
  const client = new PiRpcClient(child, dependencies.piRpcClientOptions);
1076
1062
  try {
1077
1063
  const [state, available] = await Promise.all([
@@ -1086,17 +1072,18 @@ async function createPiRpcBackend(input, dependencies, spawn, productionSpawn =
1086
1072
  || state.model?.contextWindow !== probedModel.contextWindow) {
1087
1073
  throw new Error("Pi model or context window changed between the isolated probe and runtime startup");
1088
1074
  }
1089
- const ownedSettings = readOwnedPiSettings(ownedPiDirectory);
1075
+ const ownedSettings = readOwnedPiSettings(input.workspaceDir);
1090
1076
  assertEffectivePiCompactionSettings({ contextWindow: state.model?.contextWindow, compaction: {
1091
1077
  enabled: state.autoCompactionEnabled,
1092
1078
  reserveTokens: ownedSettings.compaction?.reserveTokens,
1093
1079
  keepRecentTokens: ownedSettings.compaction?.keepRecentTokens,
1094
1080
  } });
1095
- const handshake = state.compactionCapabilities;
1096
- verifyPiCapabilities({ distribution: "external", version: reportedVersion,
1081
+ const policySource = verifyPiCapabilities({
1082
+ distribution: "external", version: reportedVersion,
1097
1083
  contextWindow: state.model?.contextWindow, autoCompactionEnabled: state.autoCompactionEnabled,
1098
- reserveTokens: handshake?.reserveTokens, keepRecentTokens: handshake?.keepRecentTokens,
1099
- compactRpc: true, events: handshake?.events });
1084
+ compactRpc: true, compactionCapabilities: state.compactionCapabilities,
1085
+ });
1086
+ process.stderr.write(`[runtime] Pi compaction policy source=${policySource} agent=${input.agentId} model=${effectiveModel} contextWindow=${state.model?.contextWindow}\n`);
1100
1087
  }
1101
1088
  // 严格对账(Owner 决策):模型必须来自 pi 的权威可用列表,不做后缀猜测——
1102
1089
  // 配置的模型与 pi 可用列表不符即报错,并在错误里列出可用模型。
@@ -1110,9 +1097,8 @@ async function createPiRpcBackend(input, dependencies, spawn, productionSpawn =
1110
1097
  }
1111
1098
  if (requestedEffort && state.thinkingLevel !== requestedEffort)
1112
1099
  throw new Error(`Pi thinking level ${requestedEffort} was not accepted by effective model ${effectiveModel || "unknown"}`);
1113
- recordPiRuntimeArtifactProvenance(ownedPiDirectory, artifactEntriesBeforeSpawn, artifactSpawnBoundary);
1114
1100
  return new PiRpcBackend(client, state, productionSpawn ? {
1115
- model: effectiveModel, contextWindow: state.model?.contextWindow, ownedPiDirectory,
1101
+ model: effectiveModel, contextWindow: state.model?.contextWindow, version: reportedVersion,
1116
1102
  } : undefined);
1117
1103
  }
1118
1104
  catch (error) {
@@ -237,6 +237,8 @@ function executableVersion(executable, env, commandArgs = []) {
237
237
  /** Resolve and handshake through each runtime's structured native control protocol. */
238
238
  async function probeNativeRuntimeReadiness(options) {
239
239
  const env = { ...process.env, ...options.env };
240
+ if (options.runtime === "pi")
241
+ delete env.PI_CODING_AGENT_DIR;
240
242
  const command = selectedCommand(options);
241
243
  const executable = resolveRuntimeExecutable(command, env);
242
244
  if (!executable) {
@@ -184,6 +184,8 @@ function executableVersion(executable, env, commandArgs = []) {
184
184
  /** Resolve and handshake through each runtime's structured native control protocol. */
185
185
  export async function probeNativeRuntimeReadiness(options) {
186
186
  const env = { ...process.env, ...options.env };
187
+ if (options.runtime === "pi")
188
+ delete env.PI_CODING_AGENT_DIR;
187
189
  const command = selectedCommand(options);
188
190
  const executable = resolveRuntimeExecutable(command, env);
189
191
  if (!executable) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "larkin",
3
- "version": "0.5.1",
3
+ "version": "0.5.2",
4
4
  "private": false,
5
5
  "larkinPackageRole": "npm-published",
6
6
  "files": [
@@ -1,139 +0,0 @@
1
- import crypto from "node:crypto";
2
- import fs from "node:fs";
3
- import path from "node:path";
4
- import { exactMode } from "../platform/secure-metadata.mjs";
5
- export const PI_RUNTIME_ARTIFACT_MANIFEST = ".larkin-pi-owned-artifacts.json";
6
- export const PI_RUNTIME_ARTIFACT_NAMES = ["models-store.json", "npm"];
7
- const MAX_FILE_BYTES = 256 * 1024 * 1024;
8
- const MAX_ENTRIES = 4096;
9
- function digest(value) {
10
- return crypto.createHash("sha256").update(value).digest("hex");
11
- }
12
- function assertSafeEntry(file, label) {
13
- const stat = fs.lstatSync(file);
14
- if (stat.isSymbolicLink())
15
- throw new Error(`${label} must not be a symlink`);
16
- if (typeof process.getuid === "function" && stat.uid !== process.getuid())
17
- throw new Error(`${label} owner is unsafe`);
18
- return stat;
19
- }
20
- function identity(file, label) {
21
- const stat = assertSafeEntry(file, label);
22
- const mode = stat.mode & 0o777;
23
- if (stat.isFile()) {
24
- if (stat.size > MAX_FILE_BYTES)
25
- throw new Error(`${label} is too large`);
26
- const bytes = fs.readFileSync(file);
27
- return { kind: "file", mode, device: String(stat.dev), inode: String(stat.ino), bytes: bytes.length, digest: digest(bytes) };
28
- }
29
- if (!stat.isDirectory())
30
- throw new Error(`${label} has an unsupported type`);
31
- const entries = fs.readdirSync(file).sort();
32
- if (entries.length > MAX_ENTRIES)
33
- throw new Error(`${label} has too many entries`);
34
- const children = entries.map((name) => {
35
- if (name === "." || name === ".." || name.includes("/"))
36
- throw new Error(`${label} contains an unsafe entry`);
37
- const child = identity(path.join(file, name), `${label} child`);
38
- return [name, child.kind, child.mode, child.device, child.inode, child.bytes, child.digest];
39
- });
40
- return {
41
- kind: "directory", mode, device: String(stat.dev), inode: String(stat.ino),
42
- bytes: children.reduce((sum, child) => sum + Number(child[5]), 0), digest: digest(JSON.stringify(children)),
43
- };
44
- }
45
- function sameIdentity(actual, expected) {
46
- return actual.kind === expected.kind && actual.mode === expected.mode
47
- && actual.device === expected.device && actual.inode === expected.inode
48
- && actual.bytes === expected.bytes && actual.digest === expected.digest;
49
- }
50
- function readManifest(file) {
51
- try {
52
- const stat = assertSafeEntry(file, "Pi artifact provenance manifest");
53
- if (!stat.isFile() || !exactMode(stat, 0o600) || stat.size > 1024 * 1024)
54
- throw new Error("Pi artifact provenance manifest is unsafe");
55
- const value = JSON.parse(fs.readFileSync(file, "utf8"));
56
- if (value?.version !== 1 || (value.owner !== "larkin-pi" && value.owner !== "larkin-builtin-pi") || !value.directory || !value.artifacts)
57
- throw new Error("Pi artifact provenance manifest is invalid");
58
- return value;
59
- }
60
- catch (error) {
61
- if (error.code === "ENOENT")
62
- return null;
63
- throw error;
64
- }
65
- }
66
- function writeManifest(file, value) {
67
- const temp = `${file}.tmp-${process.pid}`;
68
- const fd = fs.openSync(temp, "wx", 0o600);
69
- try {
70
- fs.writeFileSync(fd, `${JSON.stringify(value, null, 2)}\n`);
71
- fs.fsyncSync(fd);
72
- }
73
- finally {
74
- fs.closeSync(fd);
75
- }
76
- fs.renameSync(temp, file);
77
- }
78
- /** Record only allowed entries that were absent at the runtime spawn boundary. */
79
- export function recordPiRuntimeArtifactProvenance(targetDir, beforeEntries, boundaryAt = Date.now()) {
80
- const target = assertSafeEntry(targetDir, "Pi provider target");
81
- if (!target.isDirectory() || !exactMode(target, 0o700))
82
- throw new Error("Pi provider target is unsafe");
83
- const manifestFile = path.join(targetDir, PI_RUNTIME_ARTIFACT_MANIFEST);
84
- const existing = readManifest(manifestFile);
85
- const manifest = existing ?? {
86
- version: 1, owner: "larkin-pi", directory: { device: String(target.dev), inode: String(target.ino) },
87
- recordedAt: new Date(boundaryAt).toISOString(), artifacts: {},
88
- };
89
- if (manifest.directory.device !== String(target.dev) || manifest.directory.inode !== String(target.ino))
90
- throw new Error("Pi artifact provenance target identity changed");
91
- let changed = false;
92
- for (const [name, expected] of Object.entries(manifest.artifacts)) {
93
- if (!PI_RUNTIME_ARTIFACT_NAMES.includes(name) || name.includes("/") || !expected)
94
- throw new Error("Pi artifact provenance entry is invalid");
95
- const actual = identity(path.join(targetDir, name), `Pi artifact ${name}`);
96
- const sameOwner = actual.kind === expected.kind && actual.mode === expected.mode
97
- && actual.device === expected.device && actual.inode === expected.inode;
98
- if (!sameOwner)
99
- throw new Error(`Pi artifact ${name} provenance identity changed`);
100
- if (!sameIdentity(actual, expected)) {
101
- manifest.artifacts[name] = actual;
102
- changed = true;
103
- }
104
- }
105
- for (const name of PI_RUNTIME_ARTIFACT_NAMES) {
106
- if (beforeEntries.has(name) || manifest.artifacts[name])
107
- continue;
108
- const file = path.join(targetDir, name);
109
- if (!fs.existsSync(file))
110
- continue;
111
- const actual = identity(file, `Pi artifact ${name}`);
112
- const stat = fs.lstatSync(file);
113
- if (stat.birthtimeMs + 1 < boundaryAt && stat.mtimeMs + 1 < boundaryAt)
114
- throw new Error(`Pi artifact ${name} predates runtime provenance boundary`);
115
- manifest.artifacts[name] = actual;
116
- changed = true;
117
- }
118
- if (changed)
119
- writeManifest(manifestFile, manifest);
120
- }
121
- /** Return the names that are both explicitly attested and unchanged. */
122
- export function attestedPiRuntimeArtifactNames(targetDir) {
123
- const target = assertSafeEntry(targetDir, "Pi provider target");
124
- if (!target.isDirectory())
125
- throw new Error("Pi provider target is unsafe");
126
- const manifest = readManifest(path.join(targetDir, PI_RUNTIME_ARTIFACT_MANIFEST));
127
- if (!manifest || manifest.directory.device !== String(target.dev) || manifest.directory.inode !== String(target.ino))
128
- return new Set();
129
- const names = new Set();
130
- for (const [name, expected] of Object.entries(manifest.artifacts)) {
131
- if (!PI_RUNTIME_ARTIFACT_NAMES.includes(name) || !expected)
132
- continue;
133
- const actual = identity(path.join(targetDir, name), `Pi artifact ${name}`);
134
- if (!sameIdentity(actual, expected))
135
- throw new Error(`Pi artifact ${name} provenance changed`);
136
- names.add(name);
137
- }
138
- return names;
139
- }