@youdie006/prodex 0.40.17 → 0.40.19
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/README.md +7 -3
- package/SECURITY.md +45 -0
- package/dist/bridge-gitignore.js +47 -0
- package/dist/browser-handoff.js +29 -16
- package/dist/browser-process.js +196 -0
- package/dist/chatgpt-browser.js +51 -63
- package/dist/cli-args.js +8 -2
- package/dist/cli-pro.js +93 -8
- package/dist/cli-shared.js +15 -2
- package/dist/cli.js +2 -4
- package/dist/config.js +13 -25
- package/dist/repo.js +9 -2
- package/dist/safe-file.js +57 -13
- package/dist/store-writer.js +20 -5
- package/dist/store.js +19 -26
- package/dist/tui-run.js +1 -1
- package/docs/cli-reference.md +3 -1
- package/docs/platform-verification.md +164 -0
- package/docs/releasing.md +22 -0
- package/package.json +7 -2
- package/prodex.mjs +11 -0
- package/scripts/npm-command.mjs +71 -0
- package/scripts/release-check.mjs +18 -11
- package/scripts/release-pack.mjs +13 -4
package/dist/cli-pro.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { existsSync, realpathSync, statSync } from "node:fs";
|
|
2
2
|
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
3
3
|
import path from "node:path";
|
|
4
|
+
import { BrowserProcessInspectionError } from "./browser-process.js";
|
|
4
5
|
import { buildDryRunBundle } from "./bundle.js";
|
|
5
6
|
import { ChatGptBrowserBlockerError, DEFAULT_CDP_PORT, resolveCdpPort, resolveConversationToDelete, resolveProjectToDelete, chatGptVisibilityBlocker, defaultChatGptProfileDir, formatDurationMs, getChatGptBrowserStatus, formatModelMenuOption, listChatGptModelOptions, deleteChatGptConversation, endWedgedBrowser, browserRecoveryPlan, findWedgedBrowser, wedgedBrowserBlocker, deleteChatGptProject, listChatGptProjectsWithIds, listRecentChatGptConversations, listChatGptSidebarProjects, normalizeChatGptTargetUrl, openChatGptBrowser, openChatGptTab, parseProMode, parseReasoningEffort, defaultTimeoutForTools, ensureVirtualDisplay, minimizeChatGptWindow, readLastBrowserLoginLaunch, resolveBrowserWindowMode, recordBrowserLoginLaunch, recoverChatGptAnswerFromThread, sendChatGptPrompt, statusMeansBrowserDead, namesPro, sendWarningsFromError, destinationVerification, chatGptProjectIdFromUrl } from "./chatgpt-browser.js";
|
|
6
7
|
import { ASK_PRO_BOOLEAN_FLAGS, ASK_PRO_PREVIEW_VALUE_FLAGS, ASK_PRO_VALUE_FLAGS, assertHelpRequestArgs, assertNoExtraArgs, assertOnlyOptions, findHelpFlagIndexBeforePromptDelimiter, formatCliCommand, hasAskProDryRunMode, hasAskProMode, hasAskProSendMode, isHelpSubcommand, parseAskProArgs, printHelpIfRequested, readFlag, readPortFlag, readPositionalsWithOptions, readNonNegativeIntegerFlag, readPositiveIntegerFlag, readRepeatedFlag, resolveCwdFlag, resolveOptionalFileFlag, unknownSubcommandError } from "./cli-args.js";
|
|
@@ -391,9 +392,10 @@ export async function runProCommand(rest, io, runCliFn) {
|
|
|
391
392
|
port: opened.port,
|
|
392
393
|
headless,
|
|
393
394
|
minimized,
|
|
395
|
+
// A blocked background handoff must not make a temporary auth window permanent.
|
|
394
396
|
...(alreadyRunning && savedLaunchForPort?.resume_headless === true &&
|
|
395
|
-
!Object.values(windowModeOptions.flags).some((value) => typeof value === "boolean") &&
|
|
396
|
-
|
|
397
|
+
(background || (!Object.values(windowModeOptions.flags).some((value) => typeof value === "boolean") &&
|
|
398
|
+
!["PRODEX_HEADLESS", "PRODEX_VIRTUAL_DISPLAY", "PRODEX_MINIMIZE_WINDOW"].some((key) => (process.env[key] ?? "").trim() !== "")))
|
|
397
399
|
? { resume_headless: true } : {}),
|
|
398
400
|
...(virtualDisplay
|
|
399
401
|
? { virtual_display: virtualDisplay.displayNumber }
|
|
@@ -1543,7 +1545,7 @@ export async function runAskProCommand(rest, io, beforeSend) {
|
|
|
1543
1545
|
}
|
|
1544
1546
|
}
|
|
1545
1547
|
catch (error) {
|
|
1546
|
-
const blocker = sourceAwareBrowserBlocker(browserSendBlockerFromError(error), sourceCli, browserCommandOptions);
|
|
1548
|
+
const blocker = await withManualStepForWindowMode(sourceAwareBrowserBlocker(browserSendBlockerFromError(error), sourceCli, browserCommandOptions), resolveCdpPort(browserPort), sourceCli, browserCommandOptions);
|
|
1547
1549
|
const message = blocker.next_step ? `${blocker.message} Next: ${blocker.next_step}` : errorMessage(error);
|
|
1548
1550
|
// The blocker text can quote the requested/default project name (e.g. a
|
|
1549
1551
|
// "project not found" error). Local stdout/stderr keep it (useful to the
|
|
@@ -2318,6 +2320,7 @@ async function completeBackgroundBrowserLogin(io, options) {
|
|
|
2318
2320
|
port: opened.port,
|
|
2319
2321
|
timeoutMs: options.timeoutMs,
|
|
2320
2322
|
windowMode: { headless: true, virtualDisplay: false, minimized: false },
|
|
2323
|
+
verifiedHeadedHandoff: true,
|
|
2321
2324
|
headedLoginCommand: options.headedLoginCommand,
|
|
2322
2325
|
headlessRecoveryCommand: options.headlessRecoveryCommand
|
|
2323
2326
|
});
|
|
@@ -2386,6 +2389,12 @@ export async function waitForChatGptLoginReady(stderr, options, deps = {}) {
|
|
|
2386
2389
|
const blocker = status.blocker;
|
|
2387
2390
|
const blockerNextStep = blocker?.next_step ? ` Next: ${blocker.next_step}` : "";
|
|
2388
2391
|
if (!hasInteractiveWindow && blocker && needsVisibleAuthRecovery(blocker.code)) {
|
|
2392
|
+
if (options.verifiedHeadedHandoff) {
|
|
2393
|
+
stderr(`login: blocked - ${blocker.message}`);
|
|
2394
|
+
stderr(`login: NOT READY - ${blocker.code}. Sign-in was verified before the headless handoff, but the same profile is not ready headlessly.`);
|
|
2395
|
+
stderr("login: Another login is not a demonstrated fix. Stop automatic recovery and inspect this headless failure before another login or mode change. No further window will be opened automatically.");
|
|
2396
|
+
return false;
|
|
2397
|
+
}
|
|
2389
2398
|
const visibleStep = options.windowMode?.headless === true && options.headlessRecoveryCommand
|
|
2390
2399
|
? `run \`${options.headlessRecoveryCommand}\` to handle it visibly.`
|
|
2391
2400
|
: `${manualInspection} Handle the reported step visibly.`;
|
|
@@ -3039,6 +3048,60 @@ export async function listConsultListEntries(store, options = { readOnly: true }
|
|
|
3039
3048
|
}
|
|
3040
3049
|
return entries;
|
|
3041
3050
|
}
|
|
3051
|
+
/** Blockers whose only remedy is a person acting inside the browser window. */
|
|
3052
|
+
const MANUAL_BROWSER_STEP_CODES = new Set(["login_required", "captcha_required", "cloudflare_check"]);
|
|
3053
|
+
/**
|
|
3054
|
+
* Rewrite a "do it in the visible browser" next step for a browser that has no
|
|
3055
|
+
* window to do it in.
|
|
3056
|
+
*
|
|
3057
|
+
* Measured with a signed-out profile on a virtual display: `pro browser check`
|
|
3058
|
+
* and every send reported `login_required` with "Log in manually in the
|
|
3059
|
+
* visible browser." - on a browser prodex had itself started with no visible
|
|
3060
|
+
* window. The login command already knew the way out (close that browser, log
|
|
3061
|
+
* in through a headed one), but the blocker a send or a check prints did not.
|
|
3062
|
+
* A session that expires under a no-window setup is exactly this case, and the
|
|
3063
|
+
* advice it got could not be followed.
|
|
3064
|
+
*
|
|
3065
|
+
* Only the manual-step codes are rewritten, and only when the saved launch
|
|
3066
|
+
* belongs to the port in use, so a stale record for another browser cannot
|
|
3067
|
+
* change the advice for this one.
|
|
3068
|
+
*/
|
|
3069
|
+
export function manualBrowserStepForWindowMode(blocker, launch, port, commands) {
|
|
3070
|
+
if (!MANUAL_BROWSER_STEP_CODES.has(blocker.code))
|
|
3071
|
+
return blocker;
|
|
3072
|
+
if (!launch || launch.port !== port)
|
|
3073
|
+
return blocker;
|
|
3074
|
+
if (launch.headless === true) {
|
|
3075
|
+
return {
|
|
3076
|
+
...blocker,
|
|
3077
|
+
next_step: `The dedicated browser runs headless, with no window to do this in. Run \`${commands.visibleRecovery}\` ` +
|
|
3078
|
+
"to switch it to a visible window through the guarded path, complete the step there, then retry."
|
|
3079
|
+
};
|
|
3080
|
+
}
|
|
3081
|
+
if (launch.virtual_display !== undefined) {
|
|
3082
|
+
return {
|
|
3083
|
+
...blocker,
|
|
3084
|
+
next_step: "The dedicated browser runs on a virtual display, with no window to do this in. Close it yourself when no " +
|
|
3085
|
+
`consult is running, then run \`${commands.headedLogin}\` to complete the step in a visible window, and retry.`
|
|
3086
|
+
};
|
|
3087
|
+
}
|
|
3088
|
+
return blocker;
|
|
3089
|
+
}
|
|
3090
|
+
/** The same rewrite, with the saved launch record and the commands resolved for this call. */
|
|
3091
|
+
async function withManualStepForWindowMode(blocker, port, sourceCli, commandOptions) {
|
|
3092
|
+
if (!MANUAL_BROWSER_STEP_CODES.has(blocker.code))
|
|
3093
|
+
return blocker;
|
|
3094
|
+
const launch = await readLastBrowserLoginLaunch().catch(() => undefined);
|
|
3095
|
+
const options = {
|
|
3096
|
+
...commandOptions,
|
|
3097
|
+
port,
|
|
3098
|
+
...(launch?.profile_dir ? { profileDir: launch.profile_dir } : {})
|
|
3099
|
+
};
|
|
3100
|
+
return manualBrowserStepForWindowMode(blocker, launch, port, {
|
|
3101
|
+
headedLogin: formatHeadedBrowserLoginCommand(sourceCli, options),
|
|
3102
|
+
visibleRecovery: formatVisibleAuthRecoveryCommand(sourceCli, options)
|
|
3103
|
+
});
|
|
3104
|
+
}
|
|
3042
3105
|
function formatHeadedBrowserLoginCommand(sourceCli, options = {}) {
|
|
3043
3106
|
return `${formatBrowserLoginCommand(sourceCli, options)} --headed`;
|
|
3044
3107
|
}
|
|
@@ -3099,6 +3162,9 @@ export function printBrowserLoginGuide(stdout, input) {
|
|
|
3099
3162
|
return;
|
|
3100
3163
|
}
|
|
3101
3164
|
stdout("");
|
|
3165
|
+
stdout("Chrome account connection is separate from ChatGPT sign-in. If Chrome asks, choose whether to connect the browser account yourself.");
|
|
3166
|
+
stdout("'Use Chrome without an account' declines that browser connection, not your ChatGPT login. Keep the ChatGPT tab open after making that choice.");
|
|
3167
|
+
stdout("");
|
|
3102
3168
|
stdout("Steps:");
|
|
3103
3169
|
if (windowAvailable) {
|
|
3104
3170
|
stdout(input.reusedProfile || input.reused === true
|
|
@@ -3225,10 +3291,24 @@ export async function printProductCheck(store, io, args, configCwd = io.cwd) {
|
|
|
3225
3291
|
// report both as "not running": the browser really is gone, or it is still
|
|
3226
3292
|
// there and has stopped answering. The second keeps burning CPU until
|
|
3227
3293
|
// somebody notices, and nobody notices a message that says it is absent.
|
|
3228
|
-
|
|
3229
|
-
|
|
3230
|
-
|
|
3231
|
-
|
|
3294
|
+
let blocker = browserStatus.blocker;
|
|
3295
|
+
if (statusMeansBrowserDead(browserStatus)) {
|
|
3296
|
+
try {
|
|
3297
|
+
const wedged = findWedgedBrowser({ ...(browserCommandOptions.port !== undefined ? { port: browserCommandOptions.port } : {}) });
|
|
3298
|
+
if (wedged.length > 0)
|
|
3299
|
+
blocker = wedgedBrowserBlocker(wedged, browserCommandOptions.port ?? DEFAULT_CDP_PORT);
|
|
3300
|
+
}
|
|
3301
|
+
catch (error) {
|
|
3302
|
+
if (!(error instanceof BrowserProcessInspectionError))
|
|
3303
|
+
throw error;
|
|
3304
|
+
blocker = {
|
|
3305
|
+
code: "browser_process_unverified",
|
|
3306
|
+
message: "The operating system could not verify browser process identity.",
|
|
3307
|
+
retryable: false,
|
|
3308
|
+
next_step: `Leave the existing browser unchanged. Check OS process-inspection permissions, then run \`${formatBrowserCheckCommand(sourceCli, browserCommandOptions)}\`.`
|
|
3309
|
+
};
|
|
3310
|
+
}
|
|
3311
|
+
}
|
|
3232
3312
|
io.stdout(`chatgpt: ${blocker?.code ?? "unreachable"} - ${blocker?.message ?? "browser is not reachable"}`);
|
|
3233
3313
|
const nextStep = productCheckBrowserNextStep(blocker?.next_step, sourceCli, browserCommandOptions);
|
|
3234
3314
|
if (nextStep)
|
|
@@ -3246,7 +3326,12 @@ export async function printProductCheck(store, io, args, configCwd = io.cwd) {
|
|
|
3246
3326
|
else if (browserStatus.blocker) {
|
|
3247
3327
|
const visibilityText = browserStatus.blocker.code === "tab_not_visible" ? ` visibility=${browserStatus.visibilityState ?? "unknown"}` : "";
|
|
3248
3328
|
io.stdout(`chatgpt: blocked ${browserStatus.blocker.code}${visibilityText} - ${browserStatus.blocker.message}`);
|
|
3249
|
-
const
|
|
3329
|
+
const modeAware = await withManualStepForWindowMode(browserStatus.blocker, checkPort, sourceCli, browserCommandOptions);
|
|
3330
|
+
// A rewritten step already carries fully formed commands; running it back
|
|
3331
|
+
// through the product-check rewrite would only re-derive them.
|
|
3332
|
+
const nextStep = modeAware === browserStatus.blocker
|
|
3333
|
+
? productCheckBrowserNextStep(browserStatus.blocker.next_step, sourceCli, browserCommandOptions)
|
|
3334
|
+
: modeAware.next_step;
|
|
3250
3335
|
if (nextStep)
|
|
3251
3336
|
io.stdout(`next: ${nextStep}`);
|
|
3252
3337
|
}
|
package/dist/cli-shared.js
CHANGED
|
@@ -88,10 +88,10 @@ export function sourceAwareBrowserNextStep(nextStep, sourceCli, options = {}) {
|
|
|
88
88
|
return `\`cd ${cwdPrefix} && ${formatBrowserAskCommandBody(sourceCli)}${storedArgs ?? ""}\``;
|
|
89
89
|
})
|
|
90
90
|
.replace(/`prodex pro browser login([^`]*)?`/g, (_match, storedArgs) => {
|
|
91
|
-
return `\`${formatBrowserLoginCommand(sourceCli, browserOptionsWithStoredPort(options, storedArgs))}\``;
|
|
91
|
+
return `\`${formatBrowserLoginCommand(sourceCli, browserOptionsWithStoredPort(options, storedArgs))}${flagsBesidesPort(storedArgs)}\``;
|
|
92
92
|
})
|
|
93
93
|
.replace(/`prodex pro browser smoke([^`]*)?`/g, (_match, storedArgs) => {
|
|
94
|
-
return `\`${formatBrowserSmokeCommand(sourceCli, browserOptionsWithStoredPort(options, storedArgs))}\``;
|
|
94
|
+
return `\`${formatBrowserSmokeCommand(sourceCli, browserOptionsWithStoredPort(options, storedArgs))}${flagsBesidesPort(storedArgs)}\``;
|
|
95
95
|
})
|
|
96
96
|
.replace(/`prodex pro browser ask([^`]*)?`/g, (_match, storedArgs) => {
|
|
97
97
|
return `\`${formatBrowserAskCommandBody(sourceCli)}${storedArgs ?? ""}\``;
|
|
@@ -131,6 +131,19 @@ export function isUntrustedResultError(error) {
|
|
|
131
131
|
export function formatCommandInCwd(command, cwd) {
|
|
132
132
|
return cwd ? `cd ${shellQuote(cwd)} && ${command}` : command;
|
|
133
133
|
}
|
|
134
|
+
/**
|
|
135
|
+
* The flags of a quoted command other than --port, which the rewrite rebuilds
|
|
136
|
+
* itself. They used to be dropped: a next step saying "run `prodex pro browser
|
|
137
|
+
* login --background`" came out as a plain `login`, and "--headed" vanished from
|
|
138
|
+
* the step that exists to get a window - so following the advice relaunched the
|
|
139
|
+
* browser in the very mode it was telling the caller to leave.
|
|
140
|
+
*/
|
|
141
|
+
export function flagsBesidesPort(storedArgs) {
|
|
142
|
+
if (!storedArgs)
|
|
143
|
+
return "";
|
|
144
|
+
const rest = storedArgs.replace(/(?:^|\s)--port\s+\d{1,5}(?=\s|$)/, " ").replace(/\s+/g, " ").trim();
|
|
145
|
+
return rest ? ` ${rest}` : "";
|
|
146
|
+
}
|
|
134
147
|
export function browserOptionsWithStoredPort(options, storedArgs) {
|
|
135
148
|
if (options.port || !storedArgs)
|
|
136
149
|
return options;
|
package/dist/cli.js
CHANGED
|
@@ -14,6 +14,7 @@ import { getTokenExpiryStatus, loadLocalConfig, resolveProdexCwd } from "./confi
|
|
|
14
14
|
import { startHttpMcpServer } from "./http-mcp.js";
|
|
15
15
|
import { createMcpToolHandlers } from "./mcp-tools.js";
|
|
16
16
|
import { runMcpServer } from "./mcp.js";
|
|
17
|
+
import { execNpm } from "../scripts/npm-command.mjs";
|
|
17
18
|
import { BridgeStore } from "./store.js";
|
|
18
19
|
import { printHelpIfRequested, assertNoExtraArgs, assertOnlyOptions, formatCliCommand, formatSourceCliOption, isHelpSubcommand, readFlag, resolveCwdFlag, resolveExistingPathFlag, resolveOptionalFileFlag, shellQuote, unknownSubcommandError, unknownTopLevelCommandError } from "./cli-args.js";
|
|
19
20
|
import { listRawResultsForInspection, listTasksForInspection, runReceiptsCommand, runResultsCommand, runSessionsCommand, runTasksCommand } from "./cli-ledger.js";
|
|
@@ -711,7 +712,7 @@ async function readReleasePackageJson(packageJsonPath) {
|
|
|
711
712
|
}
|
|
712
713
|
async function readReleasePackStatus(cwd, packageJson, sourceCli, releaseHintCwd) {
|
|
713
714
|
try {
|
|
714
|
-
const { stdout } = await
|
|
715
|
+
const { stdout } = await execNpm(["pack", "--json", "--dry-run", "--ignore-scripts"], {
|
|
715
716
|
cwd,
|
|
716
717
|
timeout: 120_000,
|
|
717
718
|
maxBuffer: 20 * 1024 * 1024
|
|
@@ -872,9 +873,6 @@ function writeCommandOutput(output, write) {
|
|
|
872
873
|
for (const line of trimmed.split(/\r?\n/))
|
|
873
874
|
write(line);
|
|
874
875
|
}
|
|
875
|
-
function commandForPlatform(command) {
|
|
876
|
-
return process.platform === "win32" && command === "npm" ? "npm.cmd" : command;
|
|
877
|
-
}
|
|
878
876
|
function firstErrorLine(error) {
|
|
879
877
|
const failed = typeof error === "object" && error !== null ? error : {};
|
|
880
878
|
const stderr = firstOutputLine(failed.stderr);
|
package/dist/config.js
CHANGED
|
@@ -5,6 +5,7 @@ import { isIP } from "node:net";
|
|
|
5
5
|
import path from "node:path";
|
|
6
6
|
import { z } from "zod";
|
|
7
7
|
import { SCHEMA_VERSION } from "./schema.js";
|
|
8
|
+
import { ensureBridgeGitignore } from "./bridge-gitignore.js";
|
|
8
9
|
import { readVerifiedUtf8File, writeVerifiedUtf8File } from "./safe-file.js";
|
|
9
10
|
const BRIDGE_DIRECTORY_MODE = 0o700;
|
|
10
11
|
const BrowserDefaultsSchema = z.object({
|
|
@@ -329,30 +330,7 @@ async function ensureBridgeLocalFiles(cwd) {
|
|
|
329
330
|
const bridgeDir = path.join(cwd, ".bridge");
|
|
330
331
|
await ensurePrivateBridgeDirectory(cwd);
|
|
331
332
|
const ignorePath = path.join(bridgeDir, ".gitignore");
|
|
332
|
-
|
|
333
|
-
try {
|
|
334
|
-
current = await readVerifiedUtf8File(ignorePath, () => assertBridgeGitignoreTargetSafe(cwd));
|
|
335
|
-
}
|
|
336
|
-
catch (error) {
|
|
337
|
-
if (!isMissingFileError(error))
|
|
338
|
-
throw error;
|
|
339
|
-
}
|
|
340
|
-
const required = [
|
|
341
|
-
"tasks/*.json",
|
|
342
|
-
"results/*.json",
|
|
343
|
-
"sessions/*.json",
|
|
344
|
-
"receipts/*.json",
|
|
345
|
-
"artifacts/*",
|
|
346
|
-
"config.local.json",
|
|
347
|
-
"receipt-key.local",
|
|
348
|
-
"!.gitignore"
|
|
349
|
-
];
|
|
350
|
-
const lines = new Set(current.split(/\r?\n/).filter(Boolean));
|
|
351
|
-
for (const line of required)
|
|
352
|
-
lines.add(line);
|
|
353
|
-
await writeVerifiedUtf8File(ignorePath, `${Array.from(lines).join("\n")}\n`, () => assertBridgeGitignoreTargetSafe(cwd), {
|
|
354
|
-
create: true
|
|
355
|
-
});
|
|
333
|
+
await ensureBridgeGitignore(ignorePath, () => assertBridgeGitignoreTargetSafe(cwd));
|
|
356
334
|
}
|
|
357
335
|
async function ensurePrivateBridgeDirectory(cwd) {
|
|
358
336
|
await mkdir(path.join(cwd, ".bridge"), { recursive: true, mode: BRIDGE_DIRECTORY_MODE });
|
|
@@ -362,7 +340,9 @@ async function chmodPrivateBridgeDirectory(cwd) {
|
|
|
362
340
|
const bridgeDir = path.join(cwd, ".bridge");
|
|
363
341
|
const handle = await openNoFollowDirectory(bridgeDir, ".bridge");
|
|
364
342
|
try {
|
|
365
|
-
|
|
343
|
+
// Windows uses inherited ACLs; POSIX modes do not restrict Windows access.
|
|
344
|
+
if (process.platform !== "win32")
|
|
345
|
+
await handle.chmod(BRIDGE_DIRECTORY_MODE);
|
|
366
346
|
await assertDirectoryHandle(handle, ".bridge");
|
|
367
347
|
}
|
|
368
348
|
finally {
|
|
@@ -421,9 +401,17 @@ async function openNoFollowDirectory(dirPath, label) {
|
|
|
421
401
|
const noFollowFlag = typeof constants.O_NOFOLLOW === "number" ? constants.O_NOFOLLOW : 0;
|
|
422
402
|
const directoryFlag = typeof constants.O_DIRECTORY === "number" ? constants.O_DIRECTORY : 0;
|
|
423
403
|
try {
|
|
404
|
+
const before = await lstat(dirPath, { bigint: true });
|
|
405
|
+
if (before.isSymbolicLink() || !before.isDirectory()) {
|
|
406
|
+
throw new Error(`${label} must be a real directory and must not be a symlink`);
|
|
407
|
+
}
|
|
424
408
|
const handle = await open(dirPath, constants.O_RDONLY | directoryFlag | noFollowFlag);
|
|
425
409
|
try {
|
|
426
410
|
await assertDirectoryHandle(handle, label);
|
|
411
|
+
const opened = await handle.stat({ bigint: true });
|
|
412
|
+
if (opened.dev !== before.dev || opened.ino !== before.ino) {
|
|
413
|
+
throw new Error(`${label} changed while opening the directory`);
|
|
414
|
+
}
|
|
427
415
|
return handle;
|
|
428
416
|
}
|
|
429
417
|
catch (error) {
|
package/dist/repo.js
CHANGED
|
@@ -10,6 +10,7 @@ const execFileAsync = promisify(execFile);
|
|
|
10
10
|
export function findRipgrep(env = process.env, exists = existsSync) {
|
|
11
11
|
const pathDirs = (env.PATH ?? "").split(path.delimiter).filter(Boolean);
|
|
12
12
|
const home = env.HOME ?? env.USERPROFILE ?? "";
|
|
13
|
+
const executable = process.platform === "win32" ? "rg.exe" : "rg";
|
|
13
14
|
const fallbackDirs = [
|
|
14
15
|
"/usr/bin",
|
|
15
16
|
"/usr/local/bin",
|
|
@@ -20,7 +21,7 @@ export function findRipgrep(env = process.env, exists = existsSync) {
|
|
|
20
21
|
...(home ? [path.join(home, ".cargo", "bin"), path.join(home, ".local", "bin")] : [])
|
|
21
22
|
];
|
|
22
23
|
for (const dir of [...pathDirs, ...fallbackDirs]) {
|
|
23
|
-
const candidate = path.join(dir,
|
|
24
|
+
const candidate = path.join(dir, executable);
|
|
24
25
|
try {
|
|
25
26
|
if (exists(candidate))
|
|
26
27
|
return candidate;
|
|
@@ -47,6 +48,9 @@ export function assertRepoRelativePath(repoPath) {
|
|
|
47
48
|
if (path.isAbsolute(repoPath)) {
|
|
48
49
|
throw new Error("Path must be repo-relative, not absolute");
|
|
49
50
|
}
|
|
51
|
+
if (process.platform === "win32" && repoPath.includes(":")) {
|
|
52
|
+
throw new Error("Path must not use a Windows alternate data stream");
|
|
53
|
+
}
|
|
50
54
|
const normalized = path.posix.normalize(repoPath.replaceAll("\\", "/"));
|
|
51
55
|
if (normalized === "." || normalized.startsWith("../") || normalized === "..") {
|
|
52
56
|
throw new Error("Path must stay inside the repo-relative root");
|
|
@@ -191,11 +195,14 @@ function parseRipgrepJsonMatch(line) {
|
|
|
191
195
|
if (!isRipgrepJsonMatch(event))
|
|
192
196
|
return undefined;
|
|
193
197
|
return {
|
|
194
|
-
path: event.data.path.text
|
|
198
|
+
path: normalizeRipgrepMatchPath(event.data.path.text),
|
|
195
199
|
line: event.data.line_number,
|
|
196
200
|
text: stripOneTrailingLineEnding(event.data.lines.text)
|
|
197
201
|
};
|
|
198
202
|
}
|
|
203
|
+
function normalizeRipgrepMatchPath(matchPath) {
|
|
204
|
+
return matchPath.split(path.sep).join("/").replace(/^\.\//, "");
|
|
205
|
+
}
|
|
199
206
|
function isRipgrepJsonMatch(value) {
|
|
200
207
|
if (!isRecord(value) || value.type !== "match" || !isRecord(value.data))
|
|
201
208
|
return false;
|
package/dist/safe-file.js
CHANGED
|
@@ -74,6 +74,7 @@ export async function readVerifiedUtf8File(filePath, validate, options = {}) {
|
|
|
74
74
|
const content = await readHandleUtf8(handle, filePath, options.maxBytes);
|
|
75
75
|
if (options.mode !== undefined) {
|
|
76
76
|
await testHooks.beforeChmod?.(filePath, "read");
|
|
77
|
+
await assertSafeOpenFile(handle, filePath, "read");
|
|
77
78
|
await handle.chmod(options.mode);
|
|
78
79
|
}
|
|
79
80
|
await validate();
|
|
@@ -108,6 +109,7 @@ export async function writeVerifiedUtf8File(filePath, content, validate, options
|
|
|
108
109
|
await testHooks.afterWrite?.(filePath, "write");
|
|
109
110
|
if (options.mode !== undefined) {
|
|
110
111
|
await testHooks.beforeChmod?.(filePath, "write");
|
|
112
|
+
await assertSafeOpenFile(handle, filePath, "write");
|
|
111
113
|
await handle.chmod(options.mode);
|
|
112
114
|
}
|
|
113
115
|
await validate();
|
|
@@ -131,16 +133,17 @@ export async function replaceVerifiedUtf8File(filePath, content, validate, verif
|
|
|
131
133
|
const latestContent = await readHandleUtf8(handle, filePath, options.maxBytes);
|
|
132
134
|
await verifyCurrentContent(latestContent);
|
|
133
135
|
await testHooks.beforeWrite?.(filePath, "write");
|
|
134
|
-
await replaceByVerifiedTempFile(filePath, content, validate, options, {
|
|
135
|
-
beforeOpenAlreadyRan: true,
|
|
136
|
-
beforeWriteAlreadyRan: true,
|
|
137
|
-
parentSnapshot,
|
|
138
|
-
skipExistingTargetCheck: true
|
|
139
|
-
});
|
|
140
136
|
}
|
|
141
137
|
finally {
|
|
142
138
|
await handle.close();
|
|
143
139
|
}
|
|
140
|
+
// Windows cannot replace some files while their verification handle is open.
|
|
141
|
+
await replaceByVerifiedTempFile(filePath, content, validate, options, {
|
|
142
|
+
beforeOpenAlreadyRan: true,
|
|
143
|
+
beforeWriteAlreadyRan: true,
|
|
144
|
+
parentSnapshot,
|
|
145
|
+
skipExistingTargetCheck: true
|
|
146
|
+
});
|
|
144
147
|
}
|
|
145
148
|
async function replaceByVerifiedTempFile(filePath, content, validate, options = {}, hookState = {}) {
|
|
146
149
|
await validate();
|
|
@@ -159,6 +162,7 @@ async function replaceByVerifiedTempFile(filePath, content, validate, options =
|
|
|
159
162
|
await writeHandleUtf8(tmpHandle, tmpPath, content);
|
|
160
163
|
if (options.mode !== undefined) {
|
|
161
164
|
await testHooks.beforeChmod?.(filePath, "write");
|
|
165
|
+
await assertSafeOpenFile(tmpHandle, tmpPath, "write");
|
|
162
166
|
await tmpHandle.chmod(options.mode);
|
|
163
167
|
}
|
|
164
168
|
}
|
|
@@ -182,6 +186,7 @@ async function replaceByVerifiedTempFile(filePath, content, validate, options =
|
|
|
182
186
|
}
|
|
183
187
|
}
|
|
184
188
|
async function readHandleUtf8(handle, filePath, maxBytes) {
|
|
189
|
+
await assertOpenFileMatchesPath(handle, filePath, "read");
|
|
185
190
|
const stat = await handle.stat();
|
|
186
191
|
if (!stat.isFile()) {
|
|
187
192
|
throw new Error("Target path is not a regular file");
|
|
@@ -205,7 +210,7 @@ async function readHandleUtf8(handle, filePath, maxBytes) {
|
|
|
205
210
|
}
|
|
206
211
|
async function writeHandleUtf8(handle, filePath, content) {
|
|
207
212
|
const replacement = Buffer.from(content, "utf8");
|
|
208
|
-
await assertSafeOpenFile(handle, filePath);
|
|
213
|
+
await assertSafeOpenFile(handle, filePath, "write");
|
|
209
214
|
await handle.truncate(0);
|
|
210
215
|
let offset = 0;
|
|
211
216
|
while (offset < replacement.length) {
|
|
@@ -216,11 +221,36 @@ async function writeHandleUtf8(handle, filePath, content) {
|
|
|
216
221
|
offset += bytesWritten;
|
|
217
222
|
}
|
|
218
223
|
}
|
|
219
|
-
async function
|
|
220
|
-
const
|
|
221
|
-
if (!
|
|
224
|
+
async function assertOpenFileMatchesPath(handle, filePath, operation) {
|
|
225
|
+
const handleStat = await handle.stat({ bigint: true });
|
|
226
|
+
if (!handleStat.isFile()) {
|
|
227
|
+
throw new Error("Target path is not a regular file");
|
|
228
|
+
}
|
|
229
|
+
let pathStat;
|
|
230
|
+
try {
|
|
231
|
+
pathStat = await lstat(filePath, { bigint: true });
|
|
232
|
+
}
|
|
233
|
+
catch (error) {
|
|
234
|
+
if (isMissingFileError(error)) {
|
|
235
|
+
throw new Error(`Target path changed during ${operation} file operation`);
|
|
236
|
+
}
|
|
237
|
+
throw error;
|
|
238
|
+
}
|
|
239
|
+
if (pathStat.isSymbolicLink()) {
|
|
240
|
+
throw new Error(`Target path is a symlink or changed during ${operation} file operation`);
|
|
241
|
+
}
|
|
242
|
+
if (!pathStat.isFile()) {
|
|
222
243
|
throw new Error("Target path is not a regular file");
|
|
223
244
|
}
|
|
245
|
+
const handleIdentity = { dev: BigInt(handleStat.dev), ino: BigInt(handleStat.ino) };
|
|
246
|
+
const pathIdentity = { dev: BigInt(pathStat.dev), ino: BigInt(pathStat.ino) };
|
|
247
|
+
if (!sameFileIdentity(handleIdentity, pathIdentity)) {
|
|
248
|
+
throw new Error(`Target path changed during ${operation} file operation`);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
async function assertSafeOpenFile(handle, filePath, operation) {
|
|
252
|
+
await assertOpenFileMatchesPath(handle, filePath, operation);
|
|
253
|
+
const stat = await handle.stat();
|
|
224
254
|
assertNotHardLinked(filePath, stat.nlink);
|
|
225
255
|
}
|
|
226
256
|
function assertNotHardLinked(filePath, linkCount) {
|
|
@@ -271,8 +301,10 @@ async function captureParentSnapshot(filePath) {
|
|
|
271
301
|
};
|
|
272
302
|
}
|
|
273
303
|
async function openStableNoFollow(filePath, flags, operation, parentSnapshot, mode) {
|
|
274
|
-
if (!parentSnapshot)
|
|
275
|
-
|
|
304
|
+
if (!parentSnapshot) {
|
|
305
|
+
const handle = await openNoFollow(filePath, flags, operation, mode);
|
|
306
|
+
return verifyOpenedHandle(handle, filePath, operation);
|
|
307
|
+
}
|
|
276
308
|
if (process.platform !== "linux") {
|
|
277
309
|
const actualParentBeforeOpen = await realpath(parentSnapshot.path);
|
|
278
310
|
if (actualParentBeforeOpen !== parentSnapshot.realPath) {
|
|
@@ -284,6 +316,7 @@ async function openStableNoFollow(filePath, flags, operation, parentSnapshot, mo
|
|
|
284
316
|
if (actualParentAfterOpen !== parentSnapshot.realPath) {
|
|
285
317
|
throw new Error(`Parent directory changed during ${operation} file operation`);
|
|
286
318
|
}
|
|
319
|
+
await assertOpenFileMatchesPath(handle, filePath, operation);
|
|
287
320
|
return handle;
|
|
288
321
|
}
|
|
289
322
|
catch (error) {
|
|
@@ -298,12 +331,23 @@ async function openStableNoFollow(filePath, flags, operation, parentSnapshot, mo
|
|
|
298
331
|
if (actualParent !== parentSnapshot.realPath) {
|
|
299
332
|
throw new Error(`Parent directory changed during ${operation} file operation`);
|
|
300
333
|
}
|
|
301
|
-
|
|
334
|
+
const handle = await openNoFollow(path.join(parentFdPath, path.basename(filePath)), flags, operation, mode);
|
|
335
|
+
return await verifyOpenedHandle(handle, filePath, operation);
|
|
302
336
|
}
|
|
303
337
|
finally {
|
|
304
338
|
await parentHandle.close();
|
|
305
339
|
}
|
|
306
340
|
}
|
|
341
|
+
async function verifyOpenedHandle(handle, filePath, operation) {
|
|
342
|
+
try {
|
|
343
|
+
await assertOpenFileMatchesPath(handle, filePath, operation);
|
|
344
|
+
return handle;
|
|
345
|
+
}
|
|
346
|
+
catch (error) {
|
|
347
|
+
await handle.close().catch(() => undefined);
|
|
348
|
+
throw error;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
307
351
|
async function openNoFollowDirectory(dirPath, operation) {
|
|
308
352
|
const noFollowFlag = typeof constants.O_NOFOLLOW === "number" ? constants.O_NOFOLLOW : 0;
|
|
309
353
|
const directoryFlag = typeof constants.O_DIRECTORY === "number" ? constants.O_DIRECTORY : 0;
|
package/dist/store-writer.js
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
//
|
|
17
17
|
// Everything after the anchoring uses the same helpers the in-process path uses,
|
|
18
18
|
// on relative names.
|
|
19
|
-
import { closeSync, constants, fstatSync, openSync } from "node:fs";
|
|
19
|
+
import { closeSync, constants, fstatSync, lstatSync, openSync } from "node:fs";
|
|
20
20
|
import { pathToFileURL } from "node:url";
|
|
21
21
|
import { link, lstat, readdir, rename, rm } from "node:fs/promises";
|
|
22
22
|
import { randomUUID } from "node:crypto";
|
|
@@ -34,7 +34,22 @@ function sameIdentity(a, b) {
|
|
|
34
34
|
function openDirectoryHere(name) {
|
|
35
35
|
const directoryFlag = typeof constants.O_DIRECTORY === "number" ? constants.O_DIRECTORY : 0;
|
|
36
36
|
const noFollowFlag = typeof constants.O_NOFOLLOW === "number" ? constants.O_NOFOLLOW : 0;
|
|
37
|
-
|
|
37
|
+
const before = lstatSync(name, { bigint: true });
|
|
38
|
+
if (before.isSymbolicLink() || !before.isDirectory()) {
|
|
39
|
+
throw new Error("Anchored writer expected a real directory, not a symlink");
|
|
40
|
+
}
|
|
41
|
+
const fd = openSync(name, constants.O_RDONLY | directoryFlag | noFollowFlag);
|
|
42
|
+
try {
|
|
43
|
+
const opened = fstatSync(fd, { bigint: true });
|
|
44
|
+
if (opened.dev !== before.dev || opened.ino !== before.ino || !opened.isDirectory()) {
|
|
45
|
+
throw new Error("Anchored writer directory changed while opening");
|
|
46
|
+
}
|
|
47
|
+
return fd;
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
closeSync(fd);
|
|
51
|
+
throw error;
|
|
52
|
+
}
|
|
38
53
|
}
|
|
39
54
|
function currentDirectoryIdentity() {
|
|
40
55
|
const fd = openDirectoryHere(".");
|
|
@@ -55,10 +70,10 @@ export function anchorCurrentDirectory(anchor, segments) {
|
|
|
55
70
|
throw new Error("Anchored writer was not started in the directory the caller validated");
|
|
56
71
|
}
|
|
57
72
|
for (const segment of segments) {
|
|
58
|
-
if (segment.length === 0 || segment === "." || segment === ".." || segment.includes("
|
|
73
|
+
if (segment.length === 0 || segment === "." || segment === ".." || /[\\/]/.test(segment) || (process.platform === "win32" && segment.includes(":"))) {
|
|
59
74
|
throw new Error(`Anchored writer refuses to descend into ${JSON.stringify(segment)}`);
|
|
60
75
|
}
|
|
61
|
-
//
|
|
76
|
+
// The no-follow/identity checks reject symlinks and Windows junctions, and
|
|
62
77
|
// the identity check after chdir proves we landed on that same directory
|
|
63
78
|
// and not on something swapped in between the two calls.
|
|
64
79
|
const fd = openDirectoryHere(segment);
|
|
@@ -100,7 +115,7 @@ export async function runAnchoredJob(job) {
|
|
|
100
115
|
}
|
|
101
116
|
};
|
|
102
117
|
const { fileName } = job;
|
|
103
|
-
if (fileName.length === 0 || fileName.includes("
|
|
118
|
+
if (fileName.length === 0 || /[\\/]/.test(fileName) || (process.platform === "win32" && fileName.includes(":")) || fileName === "." || fileName === "..") {
|
|
104
119
|
throw new Error(`Anchored writer refuses the file name ${JSON.stringify(fileName)}`);
|
|
105
120
|
}
|
|
106
121
|
if (job.op === "deleteIfPresent") {
|
package/dist/store.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createHash, createHmac, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
|
|
2
2
|
import { createRequire } from "node:module";
|
|
3
3
|
import { registerBridgeRoot } from "./registry.js";
|
|
4
|
+
import { ensureBridgeGitignore } from "./bridge-gitignore.js";
|
|
4
5
|
import { closeSync, constants, existsSync, openSync, readdirSync } from "node:fs";
|
|
5
6
|
import { tmpdir } from "node:os";
|
|
6
7
|
import { link, lstat, mkdir, open, readdir, realpath, rename, rm, stat } from "node:fs/promises";
|
|
@@ -841,31 +842,7 @@ export class BridgeStore {
|
|
|
841
842
|
}
|
|
842
843
|
async ensureBridgeGitignore() {
|
|
843
844
|
const ignorePath = path.join(this.bridgeDir, ".gitignore");
|
|
844
|
-
|
|
845
|
-
try {
|
|
846
|
-
current = await readVerifiedUtf8File(ignorePath, () => this.assertBridgeGitignoreTargetSafe());
|
|
847
|
-
}
|
|
848
|
-
catch (error) {
|
|
849
|
-
if (!isErrorCode(error, "ENOENT"))
|
|
850
|
-
throw error;
|
|
851
|
-
}
|
|
852
|
-
const required = [
|
|
853
|
-
"tasks/*.json",
|
|
854
|
-
"results/*.json",
|
|
855
|
-
"sessions/*.json",
|
|
856
|
-
"receipts/*.json",
|
|
857
|
-
"artifacts/*",
|
|
858
|
-
"config.local.json",
|
|
859
|
-
"receipt-key.local",
|
|
860
|
-
"last-browser-send",
|
|
861
|
-
"!.gitignore"
|
|
862
|
-
];
|
|
863
|
-
const lines = new Set(current.split(/\r?\n/).filter(Boolean));
|
|
864
|
-
for (const line of required)
|
|
865
|
-
lines.add(line);
|
|
866
|
-
await writeVerifiedUtf8File(ignorePath, `${Array.from(lines).join("\n")}\n`, () => this.assertBridgeGitignoreTargetSafe(), {
|
|
867
|
-
create: true
|
|
868
|
-
});
|
|
845
|
+
await ensureBridgeGitignore(ignorePath, () => this.assertBridgeGitignoreTargetSafe());
|
|
869
846
|
}
|
|
870
847
|
async hasReadyBridgeStorageReadOnly() {
|
|
871
848
|
try {
|
|
@@ -1025,6 +1002,12 @@ export class BridgeStore {
|
|
|
1025
1002
|
throw new Error(`Bridge record path must stay under .bridge/${kind}`);
|
|
1026
1003
|
}
|
|
1027
1004
|
await this.assertStorageDirIsRealDirectory(kind);
|
|
1005
|
+
if (job.op === "cleanupTempHardLinks") {
|
|
1006
|
+
await storeTestHooks.beforeRecordTempCleanup?.(kind, filePath);
|
|
1007
|
+
}
|
|
1008
|
+
else if (job.op === "writeByRename" || job.op === "linkIfAbsent") {
|
|
1009
|
+
await storeTestHooks.beforeRecordRename?.(kind, filePath);
|
|
1010
|
+
}
|
|
1028
1011
|
const outcome = await runAnchoredWrite(this.bridgeDir, [kind], {
|
|
1029
1012
|
...job,
|
|
1030
1013
|
fileName: path.basename(filePath),
|
|
@@ -1549,9 +1532,17 @@ async function openNoFollowDirectory(dirPath, label) {
|
|
|
1549
1532
|
const noFollowFlag = typeof constants.O_NOFOLLOW === "number" ? constants.O_NOFOLLOW : 0;
|
|
1550
1533
|
const directoryFlag = typeof constants.O_DIRECTORY === "number" ? constants.O_DIRECTORY : 0;
|
|
1551
1534
|
try {
|
|
1535
|
+
const before = await lstat(dirPath, { bigint: true });
|
|
1536
|
+
if (before.isSymbolicLink() || !before.isDirectory()) {
|
|
1537
|
+
throw new Error(`${label} must be a real directory and must not be a symlink`);
|
|
1538
|
+
}
|
|
1552
1539
|
const handle = await open(dirPath, constants.O_RDONLY | directoryFlag | noFollowFlag);
|
|
1553
1540
|
try {
|
|
1554
1541
|
await assertOpenDirectoryHandle(handle);
|
|
1542
|
+
const opened = await handle.stat({ bigint: true });
|
|
1543
|
+
if (opened.dev !== before.dev || opened.ino !== before.ino) {
|
|
1544
|
+
throw new Error(`${label} changed while opening the directory`);
|
|
1545
|
+
}
|
|
1555
1546
|
return handle;
|
|
1556
1547
|
}
|
|
1557
1548
|
catch (error) {
|
|
@@ -1574,7 +1565,9 @@ async function ensurePrivateDirectory(dirPath, label) {
|
|
|
1574
1565
|
async function chmodPrivateDirectory(dirPath, label) {
|
|
1575
1566
|
const handle = await openNoFollowDirectory(dirPath, label);
|
|
1576
1567
|
try {
|
|
1577
|
-
|
|
1568
|
+
// Windows uses inherited ACLs; fchmod on a read-only directory is EPERM.
|
|
1569
|
+
if (process.platform !== "win32")
|
|
1570
|
+
await handle.chmod(BRIDGE_DIRECTORY_MODE);
|
|
1578
1571
|
await assertOpenDirectoryHandle(handle);
|
|
1579
1572
|
}
|
|
1580
1573
|
finally {
|
package/dist/tui-run.js
CHANGED
package/docs/cli-reference.md
CHANGED
|
@@ -33,7 +33,9 @@ Not implemented:
|
|
|
33
33
|
|
|
34
34
|
This section connects coding agents (Claude, Codex, ChatGPT Projects) to the bridge over MCP. It is not required for the standalone terminal flow above — if you only want Pro answers in your terminal, the [Quickstart](#quickstart-a-pro-second-opinion-from-your-terminal) is complete on its own.
|
|
35
35
|
|
|
36
|
-
Requires Node.js 20 or newer, `git`, and `ripgrep` (`rg`) on PATH. The optional
|
|
36
|
+
Requires Node.js 20 or newer, `git`, and `ripgrep` (`rg`) on PATH. The optional browser adapter probes PATH binaries (`google-chrome`, `chromium`, `chromium-browser`, `microsoft-edge`, `brave-browser`), native macOS app bundles, and native Windows Program Files/LOCALAPPDATA installs. Set `PRODEX_CHROME` for another executable. WSL uses Linux browser paths, not automatic Windows-host browser discovery. Do not share one profile between native Windows Chrome and Linux Chrome. See [platform verification](platform-verification.md) and [local storage permissions](../SECURITY.md#local-storage-permissions).
|
|
37
|
+
|
|
38
|
+
Native Windows command examples use PowerShell quoting, including doubled apostrophes inside single-quoted arguments. Use PowerShell 7 for compound examples containing `&&`; these examples are not `cmd.exe` commands. Internal CLI/MCP child processes receive literal argument arrays without a shell.
|
|
37
39
|
|
|
38
40
|
Install from npm — **note the scope**. The unscoped `prodex` on npm is an unrelated third-party package; do **not** install it. Use the scoped name:
|
|
39
41
|
|