@agimon-ai/po-file-edit 0.0.0-alpha.32 → 0.0.0-alpha.34

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.
@@ -47,6 +47,7 @@ ignore = __toESM(ignore, 1);
47
47
  let node_os = require("node:os");
48
48
  node_os = __toESM(node_os, 1);
49
49
  let _agimon_ai_po_config = require("@agimon-ai/po-config");
50
+ let _agimon_ai_po_core_platform = require("@agimon-ai/po-core/platform");
50
51
  let hono = require("hono");
51
52
  //#region src/constants/editPresence.ts
52
53
  const PRESENCE_NOTE_TITLE = "[Po: other active agents edited files in this result]";
@@ -1396,17 +1397,44 @@ function describe(error) {
1396
1397
  return error instanceof Error ? error.message : String(error);
1397
1398
  }
1398
1399
  /** Reads the file as the source view will show it, or says why it cannot. */
1399
- async function readWorking(filePath) {
1400
+ async function readWorking(filePath, opened, allowedRoot) {
1401
+ let handle = opened;
1400
1402
  let raw;
1401
1403
  try {
1402
- raw = await node_fs_promises.default.readFile(filePath);
1404
+ handle ??= await (0, _agimon_ai_po_core_platform.openGuardedFile)(filePath, { root: allowedRoot });
1405
+ const stats = await handle.stat();
1406
+ if (!stats.isFile()) return {
1407
+ content: "",
1408
+ hash: "",
1409
+ unavailable: true,
1410
+ reason: "the file is not a regular file"
1411
+ };
1412
+ const named = await node_fs_promises.default.lstat(filePath);
1413
+ if (!named.isFile() || stats.dev !== named.dev || stats.ino !== named.ino || allowedRoot !== void 0 && !await isRealPathInside(allowedRoot, filePath)) return {
1414
+ content: "",
1415
+ hash: "",
1416
+ unavailable: true,
1417
+ reason: "the file path changed or leaves its readable directory"
1418
+ };
1419
+ raw = Buffer.alloc(1048577);
1420
+ if (stats.size <= 1048576) {
1421
+ let offset = 0;
1422
+ while (offset < raw.length) {
1423
+ const { bytesRead } = await handle.read(raw, offset, raw.length - offset, offset);
1424
+ if (bytesRead === 0) break;
1425
+ offset += bytesRead;
1426
+ }
1427
+ raw = raw.subarray(0, offset);
1428
+ }
1403
1429
  } catch {
1404
1430
  return {
1405
1431
  content: "",
1406
1432
  hash: "",
1407
1433
  unavailable: true,
1408
- reason: "the file no longer exists"
1434
+ reason: "the file no longer exists or cannot be safely read"
1409
1435
  };
1436
+ } finally {
1437
+ if (opened === void 0) await handle?.close();
1410
1438
  }
1411
1439
  if (raw.byteLength > 1048576) return {
1412
1440
  content: "",
@@ -1579,7 +1607,7 @@ function createFileEditsApi(options = {}) {
1579
1607
  const versions = await recordedVersions(requested);
1580
1608
  if (versions === void 0 || requested === void 0) return context.json({ error: "This session recorded no changes to that file." }, 404);
1581
1609
  const filePath = node_path.default.resolve(cwd, requested);
1582
- const working = await readWorking(filePath);
1610
+ const working = await readWorking(filePath, void 0, cwd !== "" && isInside(node_path.default.resolve(cwd), filePath) ? node_path.default.resolve(cwd) : void 0);
1583
1611
  const body = {
1584
1612
  path: filePath,
1585
1613
  relPath: cwd === "" ? filePath : node_path.default.relative(cwd, filePath),
@@ -1607,11 +1635,12 @@ function createFileEditsApi(options = {}) {
1607
1635
  const root = node_path.default.resolve(cwd);
1608
1636
  const filePath = node_path.default.resolve(root, requested);
1609
1637
  const inSession = await isInsideSession(filePath);
1610
- if (!inSession && await readableRootOf(filePath) === void 0) return context.json({ error: "The path leaves the session directory." }, 403);
1638
+ const allowedRoot = inSession ? root : await readableRootOf(filePath);
1639
+ if (allowedRoot === void 0) return context.json({ error: "The path leaves the session directory." }, 403);
1611
1640
  const body = {
1612
1641
  path: filePath,
1613
1642
  relPath: inSession ? node_path.default.relative(root, filePath) : filePath,
1614
- working: await readWorking(filePath)
1643
+ working: await readWorking(filePath, void 0, allowedRoot)
1615
1644
  };
1616
1645
  return context.json(body);
1617
1646
  });
@@ -1622,19 +1651,41 @@ function createFileEditsApi(options = {}) {
1622
1651
  } catch {
1623
1652
  return context.json({ error: "The save body is not JSON." }, 400);
1624
1653
  }
1625
- if (typeof request.path !== "string" || typeof request.content !== "string") return context.json({ error: "A save names a path and the content to write." }, 400);
1626
- if (await recordedVersions(request.path) === void 0) return context.json({ error: "This session recorded no changes to that file." }, 404);
1627
- const filePath = node_path.default.resolve(cwd, request.path);
1654
+ if (typeof request !== "object" || request === null || Array.isArray(request) || !("path" in request) || typeof request.path !== "string" || !("content" in request) || typeof request.content !== "string" || !("expectedHash" in request) || typeof request.expectedHash !== "string") return context.json({ error: "A save names a path, the content to write and the hash it was read at." }, 400);
1655
+ const { path: requested, content, expectedHash } = request;
1656
+ if (await recordedVersions(requested) === void 0) return context.json({ error: "This session recorded no changes to that file." }, 404);
1657
+ const filePath = node_path.default.resolve(cwd, requested);
1628
1658
  if (!await isInsideSession(filePath)) return context.json({ error: "Files outside the session directory are read-only." }, 403);
1629
- const current = await readWorking(filePath);
1630
- if (current.unavailable) return context.json({ error: current.reason ?? "The file cannot be read." }, 409);
1631
- if (current.hash !== request.expectedHash) return context.json({
1632
- error: "The file changed since it was opened.",
1633
- hash: current.hash
1634
- }, 409);
1635
- await node_fs_promises.default.writeFile(filePath, request.content, "utf8");
1636
- const body = { hash: hashOf(request.content) };
1637
- return context.json(body);
1659
+ if (Buffer.byteLength(content, "utf8") > 1048576) return context.json({ error: `The file is larger than ${MAX_WORKING_BYTES} bytes.` }, 413);
1660
+ try {
1661
+ const canonical = await node_fs_promises.default.realpath(filePath);
1662
+ return await _agimon_ai_po_core_platform.fileMutationRunner.forKey(canonical)(async () => {
1663
+ const handle = await (0, _agimon_ai_po_core_platform.openGuardedFile)(filePath, {
1664
+ write: true,
1665
+ root: node_path.default.resolve(cwd)
1666
+ });
1667
+ try {
1668
+ if (await node_fs_promises.default.realpath(filePath) !== canonical) return context.json({ error: "The file changed before it could be written." }, 409);
1669
+ const current = await readWorking(filePath, handle, node_path.default.resolve(cwd));
1670
+ if (current.unavailable) return context.json({ error: current.reason ?? "The file cannot be read." }, 409);
1671
+ if (current.hash !== expectedHash) return context.json({
1672
+ error: "The file changed since it was opened.",
1673
+ hash: current.hash
1674
+ }, 409);
1675
+ const opened = await handle.stat();
1676
+ const named = await node_fs_promises.default.lstat(filePath);
1677
+ if (!opened.isFile() || opened.nlink !== 1 || !named.isFile() || named.nlink !== 1 || opened.dev !== named.dev || opened.ino !== named.ino || await node_fs_promises.default.realpath(filePath) !== canonical || !await isInsideSession(filePath)) return context.json({ error: "The file changed before it could be written." }, 409);
1678
+ await handle.writeFile(content, "utf8");
1679
+ await handle.truncate(Buffer.byteLength(content, "utf8"));
1680
+ const body = { hash: hashOf(content) };
1681
+ return context.json(body);
1682
+ } finally {
1683
+ await handle.close();
1684
+ }
1685
+ });
1686
+ } catch {
1687
+ return context.json({ error: "The file cannot be safely written." }, 409);
1688
+ }
1638
1689
  });
1639
1690
  app.delete(routes.remove.path, async (context) => {
1640
1691
  const requested = context.req.query(PATH_QUERY_PARAM);
@@ -1642,7 +1693,15 @@ function createFileEditsApi(options = {}) {
1642
1693
  const filePath = node_path.default.resolve(cwd, requested);
1643
1694
  if (!await isInsideSession(filePath)) return context.json({ error: "Files outside the session directory are read-only." }, 403);
1644
1695
  try {
1645
- await node_fs_promises.default.unlink(filePath);
1696
+ const canonical = await node_fs_promises.default.realpath(filePath).catch((error) => {
1697
+ if (hasCode(error, "ENOENT")) return filePath;
1698
+ throw error;
1699
+ });
1700
+ return await _agimon_ai_po_core_platform.fileMutationRunner.forKey(canonical)(async () => {
1701
+ if (!await isInsideSession(filePath)) return context.json({ error: "Files outside the session directory are read-only." }, 403);
1702
+ await node_fs_promises.default.unlink(filePath);
1703
+ return context.body(null, 204);
1704
+ });
1646
1705
  } catch (error) {
1647
1706
  if (!hasCode(error, "ENOENT")) return context.json({ error: `The file could not be deleted: ${describe(error)}` }, 409);
1648
1707
  }
@@ -1654,14 +1713,17 @@ function createFileEditsApi(options = {}) {
1654
1713
  const api = {
1655
1714
  basePath: API_BASE_PATH,
1656
1715
  start(context) {
1657
- const environment = context.environment ?? process.env;
1716
+ const environment = context.environment ?? {};
1658
1717
  const cwd = context.cwd;
1659
1718
  const app = createFileEditsApi({
1660
- paths: new FileEditPaths(context.environment),
1719
+ paths: new FileEditPaths({
1720
+ ...environment,
1721
+ HOME: context.homeDirectory ?? environment.HOME
1722
+ }),
1661
1723
  ...context.sessionId === void 0 ? {} : { sessionId: context.sessionId },
1662
1724
  ...cwd === void 0 ? {} : {
1663
1725
  cwd,
1664
- readableRoots: () => plansRoots(cwd, environment)
1726
+ readableRoots: () => plansRoots(cwd, environment, context.homeDirectory)
1665
1727
  }
1666
1728
  });
1667
1729
  return {
@@ -1675,8 +1737,8 @@ const api = {
1675
1737
  * a plan link in the transcript opens in the file view. A config that cannot be
1676
1738
  * read admits nothing extra rather than failing the preview.
1677
1739
  */
1678
- function plansRoots(cwd, environment) {
1679
- const home = environment.HOME ?? node_os.default.homedir();
1740
+ function plansRoots(cwd, environment, homeDirectory) {
1741
+ const home = homeDirectory ?? environment.HOME ?? node_os.default.homedir();
1680
1742
  try {
1681
1743
  const configured = (0, _agimon_ai_po_config.loadPoConfig)(cwd, home, environment).modes?.planning?.plansDirectory;
1682
1744
  return [(0, _agimon_ai_po_config.resolvePlanningPlansDirectory)(configured, cwd, home)];