@youdie006/prodex 0.38.0 → 0.38.1

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.
@@ -0,0 +1,210 @@
1
+ // Write a bridge record into a directory the kernel has pinned for us.
2
+ //
3
+ // The store's normal path renders an open directory handle as /proc/self/fd/N
4
+ // and joins the file name onto it, so the write lands in the directory that was
5
+ // validated rather than in one a symlink swap redirected. macOS has no
6
+ // traversable equivalent - /dev/fd/N stands in for the descriptor, not for a
7
+ // walkable directory - so that path fails there with a bare ENOENT and takes the
8
+ // whole ledger with it.
9
+ //
10
+ // This process buys the same guarantee a different way. It is spawned with its
11
+ // cwd already set to the directory to descend from, it refuses to continue
12
+ // unless "." is the very inode the parent validated, and it descends by chdir
13
+ // one no-symlink segment at a time, re-checking the inode after each step. The
14
+ // kernel holds the cwd's vnode, so once a step is confirmed nothing can redirect
15
+ // the relative paths that follow. That is precisely what the fd path bought.
16
+ //
17
+ // Everything after the anchoring uses the same helpers the in-process path uses,
18
+ // on relative names.
19
+ import { closeSync, constants, fstatSync, openSync } from "node:fs";
20
+ import { pathToFileURL } from "node:url";
21
+ import { link, lstat, readdir, rename, rm } from "node:fs/promises";
22
+ import { randomUUID } from "node:crypto";
23
+ import { writeVerifiedUtf8File } from "./safe-file.js";
24
+ function identityOfOpenDirectory(fd) {
25
+ const stat = fstatSync(fd, { bigint: true });
26
+ if (!stat.isDirectory())
27
+ throw new Error("Anchored writer expected a directory");
28
+ return { dev: stat.dev.toString(), ino: stat.ino.toString() };
29
+ }
30
+ function sameIdentity(a, b) {
31
+ return a.dev === b.dev && a.ino === b.ino;
32
+ }
33
+ /** Open a name relative to the cwd, refusing symlinks and non-directories. */
34
+ function openDirectoryHere(name) {
35
+ const directoryFlag = typeof constants.O_DIRECTORY === "number" ? constants.O_DIRECTORY : 0;
36
+ const noFollowFlag = typeof constants.O_NOFOLLOW === "number" ? constants.O_NOFOLLOW : 0;
37
+ return openSync(name, constants.O_RDONLY | directoryFlag | noFollowFlag);
38
+ }
39
+ function currentDirectoryIdentity() {
40
+ const fd = openDirectoryHere(".");
41
+ try {
42
+ return identityOfOpenDirectory(fd);
43
+ }
44
+ finally {
45
+ closeSync(fd);
46
+ }
47
+ }
48
+ /**
49
+ * Confirm the cwd is the directory the parent meant, then descend the segments
50
+ * so that the cwd ends up pinned to the directory the record belongs in.
51
+ */
52
+ export function anchorCurrentDirectory(anchor, segments) {
53
+ let here = currentDirectoryIdentity();
54
+ if (!sameIdentity(here, anchor)) {
55
+ throw new Error("Anchored writer was not started in the directory the caller validated");
56
+ }
57
+ for (const segment of segments) {
58
+ if (segment.length === 0 || segment === "." || segment === ".." || segment.includes("/")) {
59
+ throw new Error(`Anchored writer refuses to descend into ${JSON.stringify(segment)}`);
60
+ }
61
+ // O_NOFOLLOW proves the name is a real directory rather than a symlink, and
62
+ // the identity check after chdir proves we landed on that same directory
63
+ // and not on something swapped in between the two calls.
64
+ const fd = openDirectoryHere(segment);
65
+ let expected;
66
+ try {
67
+ expected = identityOfOpenDirectory(fd);
68
+ }
69
+ finally {
70
+ closeSync(fd);
71
+ }
72
+ process.chdir(segment);
73
+ here = currentDirectoryIdentity();
74
+ if (!sameIdentity(here, expected)) {
75
+ throw new Error(`Anchored writer landed somewhere other than ${JSON.stringify(segment)}`);
76
+ }
77
+ }
78
+ return here;
79
+ }
80
+ async function assertRegularFileIfExists(name) {
81
+ try {
82
+ const stat = await lstat(name);
83
+ if (stat.isSymbolicLink() || !stat.isFile()) {
84
+ throw new Error("Bridge record path must be a regular file and must not be a symlink");
85
+ }
86
+ }
87
+ catch (error) {
88
+ if (error.code !== "ENOENT")
89
+ throw error;
90
+ }
91
+ }
92
+ function temporaryName(fileName) {
93
+ return `.${fileName}.${process.pid}.${Date.now()}.${randomUUID()}.tmp`;
94
+ }
95
+ export async function runAnchoredJob(job) {
96
+ const pinned = anchorCurrentDirectory(job.anchor, job.segments);
97
+ const stillPinned = async () => {
98
+ if (!sameIdentity(currentDirectoryIdentity(), pinned)) {
99
+ throw new Error("Anchored writer's directory changed underneath it");
100
+ }
101
+ };
102
+ const { fileName } = job;
103
+ if (fileName.length === 0 || fileName.includes("/") || fileName === "." || fileName === "..") {
104
+ throw new Error(`Anchored writer refuses the file name ${JSON.stringify(fileName)}`);
105
+ }
106
+ if (job.op === "deleteIfPresent") {
107
+ let stat;
108
+ try {
109
+ stat = await lstat(fileName);
110
+ }
111
+ catch (error) {
112
+ if (error.code === "ENOENT")
113
+ return { ok: true };
114
+ throw error;
115
+ }
116
+ if (stat.isSymbolicLink() || !stat.isFile()) {
117
+ throw new Error("Bridge record path must be a regular file and must not be a symlink");
118
+ }
119
+ await rm(fileName, { force: true });
120
+ return { ok: true };
121
+ }
122
+ if (job.op === "cleanupTempHardLinks") {
123
+ let targetStat;
124
+ try {
125
+ targetStat = await lstat(fileName);
126
+ }
127
+ catch (error) {
128
+ if (error.code === "ENOENT")
129
+ return { ok: true };
130
+ throw error;
131
+ }
132
+ if (targetStat.isSymbolicLink() || !targetStat.isFile() || targetStat.nlink <= 1)
133
+ return { ok: true };
134
+ const prefix = `.${fileName}.`;
135
+ for (const entry of await readdir(".", { withFileTypes: true })) {
136
+ if (!entry.isFile() || !entry.name.startsWith(prefix) || !entry.name.endsWith(".tmp"))
137
+ continue;
138
+ const tempStat = await lstat(entry.name).catch(() => undefined);
139
+ if (!tempStat?.isFile() || tempStat.isSymbolicLink())
140
+ continue;
141
+ if (tempStat.dev === targetStat.dev && tempStat.ino === targetStat.ino) {
142
+ await rm(entry.name, { force: true });
143
+ }
144
+ }
145
+ return { ok: true };
146
+ }
147
+ const content = job.content ?? "";
148
+ const tmpName = temporaryName(fileName);
149
+ if (job.op === "writeByRename") {
150
+ await assertRegularFileIfExists(fileName);
151
+ try {
152
+ await writeVerifiedUtf8File(tmpName, content, stillPinned, { create: true, mode: job.mode });
153
+ await rename(tmpName, fileName);
154
+ await assertRegularFileIfExists(fileName);
155
+ }
156
+ catch (error) {
157
+ await rm(tmpName, { force: true }).catch(() => undefined);
158
+ throw error;
159
+ }
160
+ return { ok: true };
161
+ }
162
+ // linkIfAbsent: the hard link is what makes "create only if absent" atomic.
163
+ let linked = false;
164
+ try {
165
+ await writeVerifiedUtf8File(tmpName, content, stillPinned, { create: true, exclusive: true, mode: job.mode });
166
+ try {
167
+ await link(tmpName, fileName);
168
+ }
169
+ catch (error) {
170
+ if (error.code === "EEXIST") {
171
+ await rm(tmpName, { force: true }).catch(() => undefined);
172
+ return { ok: true, created: false };
173
+ }
174
+ throw error;
175
+ }
176
+ linked = true;
177
+ await rm(tmpName, { force: true });
178
+ await assertRegularFileIfExists(fileName);
179
+ }
180
+ catch (error) {
181
+ if (!linked)
182
+ await rm(tmpName, { force: true }).catch(() => undefined);
183
+ throw error;
184
+ }
185
+ return { ok: true, created: true };
186
+ }
187
+ async function readAllStdin() {
188
+ const chunks = [];
189
+ for await (const chunk of process.stdin)
190
+ chunks.push(Buffer.from(chunk));
191
+ return Buffer.concat(chunks).toString("utf8");
192
+ }
193
+ async function main() {
194
+ let outcome;
195
+ try {
196
+ outcome = await runAnchoredJob(JSON.parse(await readAllStdin()));
197
+ }
198
+ catch (error) {
199
+ const maybe = error;
200
+ outcome = { ok: false, error: maybe.message ?? String(error), code: maybe.code };
201
+ }
202
+ process.stdout.write(`${JSON.stringify(outcome)}\n`);
203
+ if (!outcome.ok)
204
+ process.exitCode = 1;
205
+ }
206
+ // Only run when this file is the entry point, so the exported pieces stay
207
+ // importable from tests without the process trying to read stdin.
208
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
209
+ await main();
210
+ }
package/dist/store.js CHANGED
@@ -1,8 +1,12 @@
1
1
  import { createHash, createHmac, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
2
+ import { createRequire } from "node:module";
2
3
  import { registerBridgeRoot } from "./registry.js";
3
- import { constants, existsSync } from "node:fs";
4
+ import { closeSync, constants, existsSync, openSync, readdirSync } from "node:fs";
5
+ import { tmpdir } from "node:os";
4
6
  import { link, lstat, mkdir, open, readdir, realpath, rename, rm, stat } from "node:fs/promises";
5
7
  import path from "node:path";
8
+ import { spawn } from "node:child_process";
9
+ import { fileURLToPath, pathToFileURL } from "node:url";
6
10
  import { assertRepoRelativePath } from "./repo.js";
7
11
  import { readVerifiedUtf8File, writeVerifiedUtf8File } from "./safe-file.js";
8
12
  import { makeBridgeId, nowIso, ReceiptSchema, ResultSchema, SCHEMA_VERSION, SessionSchema, TaskSchema } from "./schema.js";
@@ -656,6 +660,16 @@ export class BridgeStore {
656
660
  const artifactPath = this.resolveArtifactPath(relativePath);
657
661
  const parentPath = path.dirname(artifactPath);
658
662
  await this.assertArtifactParentDirectory(parentPath);
663
+ if (!hasStableDirectoryFdPaths()) {
664
+ const segments = path.relative(this.bridgeDir, parentPath).split(path.sep).filter((part) => part.length > 0);
665
+ await runAnchoredWrite(this.bridgeDir, segments, {
666
+ op: "deleteIfPresent",
667
+ fileName: path.basename(artifactPath),
668
+ mode: BRIDGE_FILE_MODE
669
+ });
670
+ await this.assertArtifactParentDirectory(parentPath);
671
+ return;
672
+ }
659
673
  const parentHandle = await openNoFollowDirectory(parentPath, "Artifact directory");
660
674
  try {
661
675
  const targetPath = path.join(directoryFdPath(parentHandle.fd), path.basename(artifactPath));
@@ -975,13 +989,31 @@ export class BridgeStore {
975
989
  await this.writeTextByStableStorageRename(kind, filePath, content);
976
990
  return;
977
991
  }
978
- throw new Error("Bridge record writes require stable directory file descriptor paths on this platform.");
992
+ await this.runAnchoredRecordJob(kind, filePath, { op: "writeByRename", content });
979
993
  }
980
994
  async writeTextByCreateExclusive(kind, filePath, content) {
981
995
  if (hasStableDirectoryFdPaths()) {
982
996
  return await this.writeTextByStableStorageLinkIfAbsent(kind, filePath, content);
983
997
  }
984
- throw new Error("Bridge record writes require stable directory file descriptor paths on this platform.");
998
+ const outcome = await this.runAnchoredRecordJob(kind, filePath, { op: "linkIfAbsent", content });
999
+ return outcome.created !== false;
1000
+ }
1001
+ /**
1002
+ * Run one record operation in the anchored child, with the same storage
1003
+ * directory checks the in-process path makes on either side of it.
1004
+ */
1005
+ async runAnchoredRecordJob(kind, filePath, job) {
1006
+ if (path.dirname(filePath) !== this.dir(kind)) {
1007
+ throw new Error(`Bridge record path must stay under .bridge/${kind}`);
1008
+ }
1009
+ await this.assertStorageDirIsRealDirectory(kind);
1010
+ const outcome = await runAnchoredWrite(this.bridgeDir, [kind], {
1011
+ ...job,
1012
+ fileName: path.basename(filePath),
1013
+ mode: BRIDGE_FILE_MODE
1014
+ });
1015
+ await this.assertStorageDirIsRealDirectory(kind);
1016
+ return outcome.ok ? outcome : {};
985
1017
  }
986
1018
  async deleteRecordIfPresent(kind, id) {
987
1019
  const filePath = this.pathFor(kind, id);
@@ -990,6 +1022,10 @@ export class BridgeStore {
990
1022
  if (path.dirname(filePath) !== expectedDir) {
991
1023
  throw new Error(`Bridge record path must stay under .bridge/${kind}`);
992
1024
  }
1025
+ if (!hasStableDirectoryFdPaths()) {
1026
+ await this.runAnchoredRecordJob(kind, filePath, { op: "deleteIfPresent" });
1027
+ return;
1028
+ }
993
1029
  const bridgeHandle = await openNoFollowDirectory(this.bridgeDir, "Bridge directory");
994
1030
  try {
995
1031
  const storageHandle = await openNoFollowDirectory(path.join(directoryFdPath(bridgeHandle.fd), kind), `Bridge storage directory .bridge/${kind}`);
@@ -1115,6 +1151,10 @@ export class BridgeStore {
1115
1151
  if (path.dirname(filePath) !== expectedDir) {
1116
1152
  throw new Error(`Bridge record path must stay under .bridge/${kind}`);
1117
1153
  }
1154
+ if (!hasStableDirectoryFdPaths()) {
1155
+ await this.runAnchoredRecordJob(kind, filePath, { op: "cleanupTempHardLinks" });
1156
+ return;
1157
+ }
1118
1158
  const bridgeHandle = await openNoFollowDirectory(this.bridgeDir, "Bridge directory");
1119
1159
  try {
1120
1160
  const storageHandle = await openNoFollowDirectory(path.join(directoryFdPath(bridgeHandle.fd), kind), `Bridge storage directory .bridge/${kind}`);
@@ -1552,14 +1592,112 @@ function directoryFdPath(fd) {
1552
1592
  }
1553
1593
  return `${base}/${fd}`;
1554
1594
  }
1595
+ // Whether a base can be USED, not merely whether it exists. macOS has /dev/fd,
1596
+ // so an existence check accepted it, and every record write then failed with
1597
+ // ENOENT on a path like /dev/fd/12/receipts - measured there, /dev/fd exists and
1598
+ // is not traversable, which took down the whole bridge write surface while the
1599
+ // same checks pass on Linux. Existence was never the property being relied on.
1600
+ let directoryFdBaseProbe;
1601
+ function probeDirectoryFdBase() {
1602
+ for (const base of ["/proc/self/fd", "/dev/fd"]) {
1603
+ if (!existsSync(base))
1604
+ continue;
1605
+ let fd;
1606
+ try {
1607
+ fd = openSync(tmpdir(), constants.O_RDONLY | (constants.O_DIRECTORY ?? 0));
1608
+ // Reading the directory THROUGH the rendered path is exactly what the
1609
+ // writes do, so that is what gets tested.
1610
+ readdirSync(`${base}/${fd}`);
1611
+ return base;
1612
+ }
1613
+ catch {
1614
+ // this base cannot be walked here; try the next
1615
+ }
1616
+ finally {
1617
+ if (fd !== undefined) {
1618
+ try {
1619
+ closeSync(fd);
1620
+ }
1621
+ catch {
1622
+ // best effort
1623
+ }
1624
+ }
1625
+ }
1626
+ }
1627
+ return undefined;
1628
+ }
1629
+ /** Exported so a test can hold the probe to what the running platform can do. */
1630
+ export function directoryFdPathsUsable() {
1631
+ return hasStableDirectoryFdPaths();
1632
+ }
1633
+ // Running the write in a child process whose cwd the kernel has pinned is how
1634
+ // platforms without a traversable /proc/self/fd keep the guarantee the fd path
1635
+ // gives everywhere else. See src/store-writer.ts for what the child checks.
1636
+ function anchoredWriterCommand() {
1637
+ const here = path.dirname(fileURLToPath(import.meta.url));
1638
+ const built = path.join(here, "store-writer.js");
1639
+ if (existsSync(built))
1640
+ return { command: process.execPath, args: [built] };
1641
+ const source = path.join(here, "store-writer.ts");
1642
+ if (existsSync(source)) {
1643
+ // Only reachable when running from TypeScript sources. The child's cwd is
1644
+ // the bridge directory, so tsx has to be named by absolute URL or Node
1645
+ // looks for it next to the records.
1646
+ const tsx = pathToFileURL(createRequire(import.meta.url).resolve("tsx/esm")).href;
1647
+ return { command: process.execPath, args: ["--import", tsx, source] };
1648
+ }
1649
+ throw new Error("Bridge record writes need the anchored writer, which is missing from this installation.");
1650
+ }
1651
+ async function runAnchoredWrite(bridgeDir, segments, job) {
1652
+ const bridgeHandle = await openNoFollowDirectory(bridgeDir, "Bridge directory");
1653
+ let anchor;
1654
+ try {
1655
+ const stat = await bridgeHandle.stat({ bigint: true });
1656
+ anchor = { dev: stat.dev.toString(), ino: stat.ino.toString() };
1657
+ }
1658
+ finally {
1659
+ await bridgeHandle.close();
1660
+ }
1661
+ const { command, args } = anchoredWriterCommand();
1662
+ // cwd is resolved by path here, which is exactly the lookup an attacker could
1663
+ // redirect - so the child refuses to proceed unless the directory it landed in
1664
+ // is the inode just measured through the no-follow handle.
1665
+ const child = spawn(command, args, { cwd: bridgeDir, stdio: ["pipe", "pipe", "pipe"] });
1666
+ const stdout = [];
1667
+ const stderr = [];
1668
+ child.stdout.on("data", (chunk) => stdout.push(chunk));
1669
+ child.stderr.on("data", (chunk) => stderr.push(chunk));
1670
+ const finished = new Promise((resolve, reject) => {
1671
+ child.once("error", reject);
1672
+ child.once("close", () => resolve());
1673
+ });
1674
+ child.stdin.end(JSON.stringify({ ...job, anchor, segments }));
1675
+ await finished;
1676
+ const text = Buffer.concat(stdout).toString("utf8").trim();
1677
+ let outcome;
1678
+ try {
1679
+ outcome = text.length > 0 ? JSON.parse(text) : undefined;
1680
+ }
1681
+ catch {
1682
+ outcome = undefined;
1683
+ }
1684
+ if (!outcome) {
1685
+ const detail = Buffer.concat(stderr).toString("utf8").trim() || text || "no output";
1686
+ throw new Error(`Bridge record write helper failed: ${detail}`);
1687
+ }
1688
+ if (!outcome.ok) {
1689
+ const error = new Error(outcome.error);
1690
+ if (outcome.code)
1691
+ error.code = outcome.code;
1692
+ throw error;
1693
+ }
1694
+ return outcome;
1695
+ }
1555
1696
  function directoryFdPathBase() {
1556
1697
  if (storeTestHooks.disableDirectoryFdPaths)
1557
1698
  return undefined;
1558
- if (existsSync("/proc/self/fd"))
1559
- return "/proc/self/fd";
1560
- if (existsSync("/dev/fd"))
1561
- return "/dev/fd";
1562
- return undefined;
1699
+ directoryFdBaseProbe ??= { base: probeDirectoryFdBase() };
1700
+ return directoryFdBaseProbe.base;
1563
1701
  }
1564
1702
  function assertBridgeRecordId(kind, id) {
1565
1703
  if (!isBridgeRecordId(kind, id)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@youdie006/prodex",
3
- "version": "0.38.0",
3
+ "version": "0.38.1",
4
4
  "description": "Local receipt bus for coordinating Codex execution with ChatGPT Pro/Projects consultation.",
5
5
  "author": "youdie006",
6
6
  "license": "MIT",