@youdie006/prodex 0.38.0 → 0.39.0

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.
@@ -6,7 +6,7 @@ import { mkdir, readFile, writeFile } from "node:fs/promises";
6
6
  import path from "node:path";
7
7
  import { captureBrowserDiagnostics, diagnosticsEnabled, diagnosticsNote } from "./browser-diagnostics.js";
8
8
  import os from "node:os";
9
- import { readPowerSliderSelection } from "./picker-interaction.js";
9
+ import { menuKeyboardStep, readPowerSliderSelection } from "./picker-interaction.js";
10
10
  export class ChatGptBrowserBlockerError extends Error {
11
11
  blocker;
12
12
  constructor(blocker) {
@@ -20,7 +20,13 @@ export class ChatGptBrowserBlockerError extends Error {
20
20
  // of this control rather than a model. Leaving it out meant --effort could not
21
21
  // reach the top of the slider at all, and the only route there - --model Pro -
22
22
  // answered with advice to "say --effort Pro", which then failed.
23
- const REASONING_EFFORTS = ["즉시", "중간", "높음", "매우 높음", "Pro"];
23
+ /** A full trip round the menu is far more than any real picker needs. */
24
+ const MENU_KEYBOARD_WALK_LIMIT = 16;
25
+ // Two more rungs than before. Measured live: with the model row chosen rather
26
+ // than the recommended set, the slider reads Light / Medium / High / Extra High
27
+ // / Max / Ultra, and Ultra is the top of the machine. Pro is kept because older
28
+ // pickers still name the top step that way.
29
+ const REASONING_EFFORTS = ["즉시", "중간", "높음", "매우 높음", "Max", "Ultra", "Pro"];
24
30
  const PRO_MODES = ["기본", "확장"];
25
31
  // Aliases map friendly CLI input onto the exact Korean menu labels the picker
26
32
  // clicks by text. Keys are lowercased and space-stripped before lookup.
@@ -29,19 +35,26 @@ const REASONING_EFFORT_ALIASES = {
29
35
  instant: "즉시",
30
36
  medium: "중간",
31
37
  high: "높음",
32
- max: "매우 높음",
33
38
  extrahigh: "매우 높음",
39
+ max: "매우 높음",
40
+ light: "즉시",
41
+ "가벼움": "즉시",
42
+ "최대": "Max",
43
+ ultra: "Ultra",
44
+ "울트라": "Ultra",
34
45
  pro: "Pro",
35
46
  "프로": "Pro"
36
47
  };
37
48
  // Menu labels per canonical value, verified live in both the Korean and the
38
49
  // English (US) ChatGPT UI. Matching tries every candidate so either UI works.
39
50
  const EFFORT_MENU_LABELS = {
40
- "즉시": ["즉시", "Instant"],
51
+ "즉시": ["즉시", "Instant", "Light"],
41
52
  "중간": ["중간", "Medium"],
42
53
  "높음": ["높음", "High"],
43
54
  "매우 높음": ["매우 높음", "Extra High"],
44
- Pro: ["Pro", "프로"]
55
+ Max: ["Max", "최대"],
56
+ Ultra: ["Ultra", "울트라"],
57
+ Pro: ["Pro", "프로", "6-pro", "GPT-6 Pro", "6 Pro"]
45
58
  };
46
59
  const PRO_MODE_SUBMENU_LABELS = {
47
60
  "기본": ["Pro 기본", "Pro Standard", "Standard", "기본"],
@@ -60,7 +73,7 @@ export function parseReasoningEffort(raw) {
60
73
  const alias = REASONING_EFFORT_ALIASES[trimmed.toLowerCase().replace(/\s+/g, "")];
61
74
  if (alias)
62
75
  return alias;
63
- throw new Error(`--effort must be one of ${REASONING_EFFORTS.join(", ")} (English aliases: instant, medium, high, max)`);
76
+ throw new Error(`--effort must be one of ${REASONING_EFFORTS.join(", ")} (English aliases: instant/light, medium, high, extrahigh, max, ultra)`);
64
77
  }
65
78
  /** Normalize a CLI Pro sub-mode value onto the exact ChatGPT menu label. */
66
79
  export function parseProMode(raw) {
@@ -1422,6 +1435,38 @@ export function powerSliderStateExpression() {
1422
1435
  };
1423
1436
  })()`;
1424
1437
  }
1438
+ export function activeMenuItemExpression() {
1439
+ return `(() => {
1440
+ const a = document.activeElement;
1441
+ if (!a) return null;
1442
+ const menu = document.querySelector('[data-testid="composer-intelligence-picker-content"]');
1443
+ if (menu && !menu.contains(a)) return null;
1444
+ const label = ((a.innerText || a.textContent || "").trim().split(String.fromCharCode(10))[0] || "").trim();
1445
+ return { role: a.getAttribute("role"), label };
1446
+ })()`;
1447
+ }
1448
+ export function powerSliderPresentExpression() {
1449
+ return `Boolean(document.querySelector('[data-testid="composer-intelligence-picker-content"] [role="slider"]'))`;
1450
+ }
1451
+ export function pickerClosedExpression() {
1452
+ return `!document.querySelector('[data-testid="composer-intelligence-picker-content"]')`;
1453
+ }
1454
+ export function focusPickerMenuExpression() {
1455
+ return `(() => {
1456
+ const menu = document.querySelector('[data-testid="composer-intelligence-picker-content"]');
1457
+ if (!menu) return { ok: false, reason: "picker menu not open" };
1458
+ // Leave the focus the menu gave itself. It opens on the checked model row,
1459
+ // and ArrowDown from there walks the model list. Focusing the first item
1460
+ // instead lands on the label row above the slider, where ArrowDown only
1461
+ // cycles that row's own widgets - measured as four steps that repeat
1462
+ // forever and never reach a radio.
1463
+ if (menu.contains(document.activeElement)) return { ok: true };
1464
+ const radios = [...menu.querySelectorAll('[role="menuitemradio"]')];
1465
+ const target = radios.find((r) => r.getAttribute("aria-checked") === "true") || radios[0];
1466
+ if (target && typeof target.focus === "function") target.focus();
1467
+ return { ok: menu.contains(document.activeElement) };
1468
+ })()`;
1469
+ }
1425
1470
  export function focusPowerSliderExpression() {
1426
1471
  return `(() => {
1427
1472
  const slider = document.querySelector('[role="slider"]');
@@ -1680,7 +1725,45 @@ export function stepSelectionUnavailableWarning(requested, offered) {
1680
1725
  return (`step_not_applied: this ChatGPT picker has no "${requested}" step, so the send used the slider's current setting.${list} ` +
1681
1726
  'Pick one of those with --effort, or clear a saved default with `prodex setup --clear-model`.');
1682
1727
  }
1728
+ /**
1729
+ * Choose a model row through the menu's roving focus.
1730
+ *
1731
+ * The rows carry pointer-events: none and sit outside the menu's box, so
1732
+ * verifiedClickAt refuses them and .focus() is declined. ArrowDown from the row
1733
+ * the menu opens on does reach them, and Enter commits. Doing this BEFORE the
1734
+ * slider matters: with the recommended set active the slider walks a short
1735
+ * mixed ladder ending at Extra High, and picking the model swaps in that
1736
+ * model's own ladder, which continues past it.
1737
+ */
1738
+ async function selectPickerModelByKeyboard(cdp, candidates) {
1739
+ const focused = await cdp.evaluate(focusPickerMenuExpression());
1740
+ if (!focused?.ok)
1741
+ return false;
1742
+ const limit = MENU_KEYBOARD_WALK_LIMIT;
1743
+ for (let pressed = 0;; pressed += 1) {
1744
+ const active = await cdp.evaluate(activeMenuItemExpression());
1745
+ const move = menuKeyboardStep({ active, requested: candidates, pressed, limit });
1746
+ if (move === "exhausted")
1747
+ return false;
1748
+ if (move === "select") {
1749
+ await dispatchMenuKey(cdp, "Enter", 13);
1750
+ // Committing closes the menu. Waiting for it to finish closing keeps the
1751
+ // reopen below from landing mid-animation and toggling it shut again.
1752
+ await waitForExpressionTrue(cdp, pickerClosedExpression(), 3_000);
1753
+ return true;
1754
+ }
1755
+ await dispatchMenuKey(cdp, "ArrowDown", 40);
1756
+ await sleep(140);
1757
+ }
1758
+ }
1759
+ async function dispatchMenuKey(cdp, key, code) {
1760
+ for (const type of ["keyDown", "keyUp"]) {
1761
+ await cdp.send("Input.dispatchKeyEvent", { type, key, code: key, windowsVirtualKeyCode: code });
1762
+ }
1763
+ }
1683
1764
  async function selectPickerModel(cdp, requested, warnings = []) {
1765
+ if (await selectPickerModelByKeyboard(cdp, [requested]))
1766
+ return;
1684
1767
  const hit = await cdp.evaluate(menuItemRectExpression(requested));
1685
1768
  if (!hit.ok || hit.x === undefined || hit.y === undefined) {
1686
1769
  const unavailable = modelSelectionUnavailableWarning(requested, hit.reason ?? "", hit.available);
@@ -1692,6 +1775,29 @@ async function selectPickerModel(cdp, requested, warnings = []) {
1692
1775
  }
1693
1776
  await verifiedClickWithRetry(cdp, () => cdp.evaluate(menuItemRectExpression(requested)), requested);
1694
1777
  }
1778
+ /**
1779
+ * Make sure the composer picker is open, reopening it if it is not.
1780
+ *
1781
+ * Committing a model row closes the menu, and the slider lives inside it - so
1782
+ * without this the step that follows reported "the picker did not expose its
1783
+ * power slider" on a picker that was simply shut.
1784
+ */
1785
+ async function ensurePickerOpen(cdp) {
1786
+ // The slider, not the menu, is what the caller needs - and an open menu that
1787
+ // has not painted its slider yet looks identical to one that has.
1788
+ for (let attempt = 0; attempt < 3; attempt += 1) {
1789
+ if (await cdp.evaluate(powerSliderPresentExpression()))
1790
+ return true;
1791
+ await waitForExpressionTrue(cdp, pickerClosedExpression(), 1_500);
1792
+ const button = await cdp.evaluate(modelButtonRectExpression());
1793
+ if (!button.ok || button.x === undefined || button.y === undefined)
1794
+ return false;
1795
+ await verifiedClickAt(cdp, button.x, button.y, "model selector");
1796
+ if (await waitForExpressionTrue(cdp, powerSliderPresentExpression(), MENU_OPEN_TIMEOUT_MS))
1797
+ return true;
1798
+ }
1799
+ return false;
1800
+ }
1695
1801
  async function selectPowerStep(cdp, requested) {
1696
1802
  // The menu paints a moment after it opens, and on a page that has just been
1697
1803
  // built - a project created seconds ago - that moment is longer. Failing the
@@ -1800,7 +1906,11 @@ async function selectModelReasoning(cdp, options, selectionWarnings = []) {
1800
1906
  // the whole send). Retry briefly with FRESH coordinates; a persistent
1801
1907
  // cover still fails with the refusal message.
1802
1908
  const clickDeadline = Date.now() + 5_000;
1803
- for (;;) {
1909
+ // Clicking the trigger TOGGLES the picker, so clicking one that is already
1910
+ // open shuts it and the next step reports "power slider not found" about a
1911
+ // control that was on screen a moment earlier.
1912
+ const alreadyOpen = await cdp.evaluate(powerSliderPresentExpression());
1913
+ for (; !alreadyOpen;) {
1804
1914
  try {
1805
1915
  await verifiedClickAt(cdp, button.x, button.y, "model selector");
1806
1916
  break;
@@ -1832,7 +1942,16 @@ async function selectModelReasoning(cdp, options, selectionWarnings = []) {
1832
1942
  });
1833
1943
  if (plan.warning)
1834
1944
  selectionWarnings.push(plan.warning);
1945
+ // The model goes first: choosing it replaces the ladder the slider walks,
1946
+ // so a step picked before it would be a step of the old ladder.
1947
+ if (plan.modelLabel) {
1948
+ await selectPickerModel(cdp, plan.modelLabel, selectionWarnings);
1949
+ }
1835
1950
  if (plan.sliderLabel) {
1951
+ // Committing the model closed the menu; the slider is inside it.
1952
+ if (!(await ensurePickerOpen(cdp))) {
1953
+ throw new Error("ChatGPT's model picker would not reopen after the model was chosen.");
1954
+ }
1836
1955
  try {
1837
1956
  await selectPowerStep(cdp, plan.sliderLabel);
1838
1957
  }
@@ -1847,9 +1966,6 @@ async function selectModelReasoning(cdp, options, selectionWarnings = []) {
1847
1966
  selectionWarnings.push(stepSelectionUnavailableWarning(plan.sliderLabel, offered));
1848
1967
  }
1849
1968
  }
1850
- if (plan.modelLabel) {
1851
- await selectPickerModel(cdp, plan.modelLabel, selectionWarnings);
1852
- }
1853
1969
  // This branch cannot honour a sub-mode, and used to return without
1854
1970
  // saying so - the caller got a clean receipt for a 확장 it never got.
1855
1971
  const proModeWarning = proModeNotAppliedWarning(options.proMode, sliderState.effort ?? undefined);
@@ -2839,7 +2955,11 @@ export function modelMenuOptionsExpression() {
2839
2955
  return `(() => {
2840
2956
  const m = document.querySelector('[data-testid="composer-intelligence-picker-content"]');
2841
2957
  if (!m) return [];
2842
- return [...m.querySelectorAll('[role="menuitemradio"],[role="menuitem"]')]
2958
+ // Only the radios are models. The plain menuitems above them are the power
2959
+ // slider's own rows - its track, and the label pair naming the current model
2960
+ // and effort - and listing those put GPT-6 Astra in twice and Extra High in
2961
+ // as if it were a model.
2962
+ return [...m.querySelectorAll('[role="menuitemradio"]')]
2843
2963
  .map((it) => {
2844
2964
  // A submenu row renders as label over value ("Model" / "GPT-5.6 Sol"),
2845
2965
  // and the value is the part a person actually wants to read.
@@ -3275,17 +3395,24 @@ async function readEffortSteps(cdp) {
3275
3395
  await dispatchArrowKey(cdp, "ArrowLeft");
3276
3396
  await sleep(120);
3277
3397
  }
3278
- const labels = [];
3398
+ // Every position is recorded, including repeats. One slider now walks a
3399
+ // ladder of model-and-effort pairs, so "Light" is three different rungs -
3400
+ // collapsing by label turned six positions into three and lost which model
3401
+ // each one sends with.
3402
+ const rungs = [];
3279
3403
  const record = async () => {
3280
3404
  const state = await cdp.evaluate(powerSliderStateExpression());
3281
- const label = state?.effort?.trim();
3282
- if (label && !labels.includes(label))
3283
- labels.push(label);
3405
+ if (!state?.ok)
3406
+ return;
3407
+ const effort = state.effort?.trim();
3408
+ if (!effort)
3409
+ return;
3410
+ rungs.push({ position: state.position ?? rungs.length, model: state.model?.trim() || null, effort });
3284
3411
  };
3285
3412
  await record();
3286
3413
  for (let i = 0; i < plan.climb; i += 1) {
3287
3414
  await dispatchArrowKey(cdp, "ArrowRight");
3288
- await sleep(120);
3415
+ await sleep(220);
3289
3416
  await record();
3290
3417
  }
3291
3418
  // Put it back. Reading someone's setting must not change it.
@@ -3293,7 +3420,7 @@ async function readEffortSteps(cdp) {
3293
3420
  await dispatchArrowKey(cdp, "ArrowLeft");
3294
3421
  await sleep(120);
3295
3422
  }
3296
- return labels.length > 0 ? { labels, current } : undefined;
3423
+ return rungs.length > 0 ? { rungs, current } : undefined;
3297
3424
  }
3298
3425
  catch {
3299
3426
  return undefined;
package/dist/cli-pro.js CHANGED
@@ -449,11 +449,18 @@ export async function runProCommand(rest, io, runCliFn) {
449
449
  }
450
450
  if (listed.effortSteps) {
451
451
  io.stdout("");
452
- io.stdout("Effort steps on this account (the slider was walked and put back):");
453
- for (const label of listed.effortSteps.labels) {
454
- io.stdout(`${label === listed.effortSteps.current ? "*" : " "} ${label}`);
452
+ // One slider walks a ladder of model-and-effort pairs, so the same
453
+ // effort name appears at more than one rung with a different model
454
+ // behind it. Printing bare names hid that.
455
+ io.stdout("Power slider on this account (the slider was walked and put back):");
456
+ const rungs = listed.effortSteps.rungs;
457
+ const width = String(rungs.length).length;
458
+ for (const rung of rungs) {
459
+ const here = rung.effort === listed.effortSteps.current ? "*" : " ";
460
+ const step = String(rung.position + 1).padStart(width, " ");
461
+ io.stdout(`${here} ${step}/${rungs.length} ${rung.model ?? "?"} - ${rung.effort}`);
455
462
  }
456
- io.stdout("Pass any of these to --effort. The list is complete: the slider shows one step at a time, so reading it any other way sees only the current one.");
463
+ io.stdout("Each row is one position of that single slider: it sets the model and the effort together, so they cannot be chosen apart. The list is complete - the slider shows one position at a time, so reading it any other way sees only the current one.");
457
464
  }
458
465
  else {
459
466
  io.stdout("An arrow shows what that row is set to now; --model / --effort reach into those submenus (e.g. --model Pro).");
package/dist/config.js CHANGED
@@ -11,7 +11,7 @@ const BrowserDefaultsSchema = z.object({
11
11
  model: z.string().min(1).optional(),
12
12
  pro_mode: z.enum(["기본", "확장"]).optional(),
13
13
  // Pro is the slider's fifth step, so a saved default may name it.
14
- effort: z.enum(["즉시", "중간", "높음", "매우 높음", "Pro"]).optional(),
14
+ effort: z.enum(["즉시", "중간", "높음", "매우 높음", "Max", "Ultra", "Pro"]).optional(),
15
15
  project: z.string().min(1).optional()
16
16
  });
17
17
  const LocalConfigSchema = z.object({
@@ -10,6 +10,7 @@ export function readPowerSliderSelection(snapshot) {
10
10
  }));
11
11
  const powerLabels = new Set([
12
12
  "instant",
13
+ "light",
13
14
  "즉시",
14
15
  "빠름",
15
16
  "fast",
@@ -29,12 +30,43 @@ export function readPowerSliderSelection(snapshot) {
29
30
  const legacyModel = items.find((item) => item.lines[0] === "Model")?.lines[1] ?? null;
30
31
  const legacyEffort = items.find((item) => item.lines[0] === "Effort")?.lines[1] ?? null;
31
32
  const checkedModel = items.find((item) => item.role === "menuitemradio" && item.checked)?.lines[0] ?? null;
32
- const sliderOwner = items.find((item) => item.role === "menuitem" && item.containsSlider)?.lines[0] ?? null;
33
+ // The slider stopped being an effort control: one slider now walks a ladder of
34
+ // model-and-effort pairs, and a row above the track reads "<model>" then
35
+ // "<effort>". Measured live, that pair sits in a SIBLING row - the row that
36
+ // literally contains the slider carries no text - so the pair is found by its
37
+ // shape rather than by ownership of the track. Reading the pair's first line
38
+ // as the effort is what made the listing announce "Extra High" as a model.
39
+ const pairRow = items.find((item) => item.role === "menuitem" && item.lines.length >= 2 && item.lines[0] !== "Model" && item.lines[0] !== "Effort");
40
+ const owner = items.find((item) => item.role === "menuitem" && item.containsSlider);
41
+ const sliderOwnerModel = pairRow?.lines[0] ?? null;
42
+ // A single-line row is the older form, where it carried only the step name.
43
+ const sliderOwner = pairRow?.lines[1] ?? (owner && owner.lines.length === 1 ? owner.lines[0] : null) ?? null;
33
44
  const visiblePowerLabel = items.find((item) => item.role === "menuitem" && powerLabels.has((item.lines[0] ?? "").toLowerCase()))?.lines[0] ?? null;
34
45
  const accessibleCandidate = snapshot.sliderValueText?.trim() || null;
35
46
  const accessibleValue = accessibleCandidate && powerLabels.has(accessibleCandidate.toLowerCase()) ? accessibleCandidate : null;
36
47
  return {
37
- model: legacyModel ?? checkedModel,
48
+ // The checked radio is "Default" - the recommended set - while the slider
49
+ // decides the model a send actually uses, so the slider's row wins.
50
+ model: legacyModel ?? sliderOwnerModel ?? checkedModel,
38
51
  effort: accessibleValue ?? legacyEffort ?? sliderOwner ?? visiblePowerLabel
39
52
  };
40
53
  }
54
+ /**
55
+ * Decide the next move when walking the composer picker's menu by keyboard.
56
+ *
57
+ * The model rows carry pointer-events: none and refuse focus(), so the menu's
58
+ * own roving focus is the only way to reach them. Only a radio counts as the
59
+ * target: the row above the slider repeats the model name as its first line,
60
+ * and committing there selects nothing.
61
+ */
62
+ export function menuKeyboardStep(input) {
63
+ const { active, requested, pressed, limit } = input;
64
+ if (!active)
65
+ return "exhausted";
66
+ if (active.role === "menuitemradio") {
67
+ const label = active.label.trim().toLowerCase();
68
+ if (requested.some((candidate) => candidate.trim().toLowerCase() === label))
69
+ return "select";
70
+ }
71
+ return pressed < limit ? "down" : "exhausted";
72
+ }
@@ -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.39.0",
4
4
  "description": "Local receipt bus for coordinating Codex execution with ChatGPT Pro/Projects consultation.",
5
5
  "author": "youdie006",
6
6
  "license": "MIT",