@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.
- package/dist/chatgpt-browser.js +144 -17
- package/dist/cli-pro.js +11 -4
- package/dist/config.js +1 -1
- package/dist/picker-interaction.js +34 -2
- package/dist/store-writer.js +210 -0
- package/dist/store.js +146 -8
- package/package.json +1 -1
package/dist/chatgpt-browser.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3282
|
-
|
|
3283
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
453
|
-
|
|
454
|
-
|
|
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("
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1559
|
-
|
|
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)) {
|