browser-debugger-cli 0.14.0 → 0.16.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.
Files changed (167) hide show
  1. package/.claude/skills/bdg/SKILL.md +3 -2
  2. package/dist/cdp/methodTarget.d.ts +92 -0
  3. package/dist/cdp/methodTarget.js +159 -0
  4. package/dist/cdp/protocol.d.ts +16 -1
  5. package/dist/cdp/protocol.js +21 -0
  6. package/dist/cdp/schema.d.ts +55 -1
  7. package/dist/cdp/schema.js +134 -25
  8. package/dist/cdp/types.d.ts +3 -1
  9. package/dist/commands/cdp.d.ts +38 -1
  10. package/dist/commands/cdp.js +201 -133
  11. package/dist/commands/cleanup.js +21 -4
  12. package/dist/commands/dom/eval.d.ts +2 -1
  13. package/dist/commands/dom/eval.js +6 -21
  14. package/dist/commands/dom/formInteraction.js +8 -4
  15. package/dist/commands/dom/helpers/evalResult.d.ts +36 -0
  16. package/dist/commands/dom/helpers/evalResult.js +59 -0
  17. package/dist/commands/dom/helpers/index.d.ts +4 -4
  18. package/dist/commands/dom/helpers/index.js +3 -3
  19. package/dist/commands/dom/helpers/query.d.ts +2 -2
  20. package/dist/commands/dom/helpers/query.js +2 -2
  21. package/dist/commands/dom/helpers/screenshot.d.ts +21 -26
  22. package/dist/commands/dom/helpers/screenshot.js +50 -668
  23. package/dist/commands/dom/screenshot.js +56 -36
  24. package/dist/commands/helpJson.d.ts +1 -1
  25. package/dist/commands/helpJson.js +3 -3
  26. package/dist/commands/helpTopic.js +10 -4
  27. package/dist/commands/network/har.js +18 -14
  28. package/dist/commands/optionBehaviors.js +24 -9
  29. package/dist/commands/shared/CommandRunner.d.ts +5 -0
  30. package/dist/commands/shared/CommandRunner.js +18 -3
  31. package/dist/commands/shared/interrupt.d.ts +40 -0
  32. package/dist/commands/shared/interrupt.js +73 -0
  33. package/dist/commands/shared/optionTypes.d.ts +3 -0
  34. package/dist/commands/shared/outputFile.d.ts +2 -1
  35. package/dist/commands/shared/outputFile.js +7 -4
  36. package/dist/commands/shared/startHelpers.d.ts +26 -3
  37. package/dist/commands/shared/startHelpers.js +145 -23
  38. package/dist/commands/status.js +3 -1
  39. package/dist/commands/stop.js +2 -1
  40. package/dist/commands/types.d.ts +5 -0
  41. package/dist/connection/cdp.js +1 -16
  42. package/dist/connection/chromeIdentity.d.ts +24 -5
  43. package/dist/connection/chromeIdentity.js +53 -22
  44. package/dist/connection/launcher/flagsBuilder.d.ts +46 -0
  45. package/dist/connection/launcher/flagsBuilder.js +107 -23
  46. package/dist/connection/launcher.d.ts +35 -2
  47. package/dist/connection/launcher.js +99 -12
  48. package/dist/connection/typed-cdp.d.ts +3 -2
  49. package/dist/constants.d.ts +3 -5
  50. package/dist/constants.js +3 -5
  51. package/dist/daemon/SessionController.d.ts +10 -5
  52. package/dist/daemon/SessionController.js +15 -8
  53. package/dist/daemon/ipcServer.js +1 -1
  54. package/dist/daemon/launcher.d.ts +22 -3
  55. package/dist/daemon/launcher.js +45 -8
  56. package/dist/daemon/session/Session.d.ts +5 -1
  57. package/dist/daemon/session/Session.js +9 -8
  58. package/dist/daemon/session/TelemetryStore.d.ts +5 -0
  59. package/dist/daemon/session/TelemetryStore.js +4 -0
  60. package/dist/daemon/session/captureGate.d.ts +59 -0
  61. package/dist/daemon/session/captureGate.js +96 -0
  62. package/dist/daemon/session/chromeConnection.d.ts +16 -1
  63. package/dist/daemon/session/chromeConnection.js +34 -4
  64. package/dist/daemon/session/collectors.d.ts +15 -0
  65. package/dist/daemon/session/collectors.js +39 -2
  66. package/dist/daemon/session/commandRegistry.d.ts +14 -1
  67. package/dist/daemon/session/commandRegistry.js +48 -13
  68. package/dist/daemon/session/downloads.d.ts +32 -0
  69. package/dist/daemon/session/downloads.js +96 -0
  70. package/dist/daemon/session/interactions.d.ts +3 -2
  71. package/dist/daemon/session/interactions.js +7 -2
  72. package/dist/daemon/session/plugins.js +6 -0
  73. package/dist/daemon.js +18520 -17014
  74. package/dist/errors/CommandError.d.ts +2 -0
  75. package/dist/errors/issues.d.ts +1 -1
  76. package/dist/errors/messages.d.ts +81 -0
  77. package/dist/errors/messages.js +198 -6
  78. package/dist/index.js +1446 -1078
  79. package/dist/ipc/client.d.ts +20 -2
  80. package/dist/ipc/client.js +32 -6
  81. package/dist/ipc/protocol/commands.d.ts +36 -2
  82. package/dist/ipc/protocol/commands.js +1 -0
  83. package/dist/ipc/protocol/domTypes.d.ts +24 -1
  84. package/dist/ipc/session/queries.d.ts +3 -0
  85. package/dist/ipc/session/types.d.ts +5 -0
  86. package/dist/ipc/transport/IPCError.d.ts +9 -0
  87. package/dist/ipc/transport/IPCError.js +12 -0
  88. package/dist/ipc/transport/errors.d.ts +2 -1
  89. package/dist/ipc/transport/errors.js +4 -1
  90. package/dist/ipc/transport/index.d.ts +10 -2
  91. package/dist/ipc/transport/index.js +29 -4
  92. package/dist/runtime/dom/actionEffects.d.ts +48 -9
  93. package/dist/runtime/dom/actionEffects.js +269 -34
  94. package/dist/runtime/dom/actionEffectsScripts.d.ts +45 -0
  95. package/dist/runtime/dom/actionEffectsScripts.js +101 -2
  96. package/dist/runtime/dom/captureArea.d.ts +35 -0
  97. package/dist/runtime/dom/captureArea.js +203 -0
  98. package/dist/runtime/dom/elementInfo.d.ts +13 -4
  99. package/dist/runtime/dom/elementInfo.js +12 -3
  100. package/dist/runtime/dom/evalHelpers.d.ts +24 -4
  101. package/dist/runtime/dom/evalHelpers.js +40 -12
  102. package/dist/runtime/dom/formDiscovery.d.ts +1 -1
  103. package/dist/runtime/dom/frames.d.ts +2 -1
  104. package/dist/runtime/dom/frames.js +3 -1
  105. package/dist/runtime/page/bdgWorld.d.ts +9 -0
  106. package/dist/runtime/page/bdgWorld.js +11 -0
  107. package/dist/runtime/page/captureEmulation.d.ts +119 -0
  108. package/dist/runtime/page/captureEmulation.js +189 -0
  109. package/dist/runtime/page/captureScroll.d.ts +24 -0
  110. package/dist/runtime/page/captureScroll.js +124 -0
  111. package/dist/runtime/page/emulation.js +6 -5
  112. package/dist/runtime/page/screenshot.d.ts +41 -0
  113. package/dist/runtime/page/screenshot.js +394 -0
  114. package/dist/runtime/page/userAgent.d.ts +86 -2
  115. package/dist/runtime/page/userAgent.js +154 -33
  116. package/dist/session/paths.d.ts +52 -3
  117. package/dist/session/paths.js +179 -7
  118. package/dist/session/portClaims.d.ts +0 -8
  119. package/dist/session/portClaims.js +1 -22
  120. package/dist/session/sessionList.d.ts +5 -1
  121. package/dist/session/sessionList.js +5 -1
  122. package/dist/telemetry/downloads.d.ts +127 -0
  123. package/dist/telemetry/downloads.js +265 -0
  124. package/dist/telemetry/har/builder.d.ts +12 -1
  125. package/dist/telemetry/har/builder.js +32 -9
  126. package/dist/telemetry/har/sanitize.d.ts +28 -0
  127. package/dist/telemetry/har/sanitize.js +184 -0
  128. package/dist/telemetry/har/sanitizeBody.d.ts +78 -0
  129. package/dist/telemetry/har/sanitizeBody.js +541 -0
  130. package/dist/telemetry/har/types.d.ts +2 -0
  131. package/dist/telemetry/network.d.ts +4 -4
  132. package/dist/telemetry/network.js +38 -4
  133. package/dist/telemetry/networkRetention.d.ts +35 -14
  134. package/dist/telemetry/networkRetention.js +62 -26
  135. package/dist/types.d.ts +9 -14
  136. package/dist/ui/OutputBuilder.d.ts +3 -2
  137. package/dist/ui/OutputBuilder.js +4 -3
  138. package/dist/ui/formatters/cdp.d.ts +32 -9
  139. package/dist/ui/formatters/cdp.js +77 -6
  140. package/dist/ui/formatters/details.js +7 -15
  141. package/dist/ui/formatters/preview.d.ts +2 -0
  142. package/dist/ui/formatters/preview.js +7 -1
  143. package/dist/ui/formatters/sessions.d.ts +3 -2
  144. package/dist/ui/formatters/sessions.js +10 -3
  145. package/dist/ui/formatters/status.js +6 -1
  146. package/dist/ui/formatting.d.ts +7 -0
  147. package/dist/ui/formatting.js +13 -0
  148. package/dist/ui/logging/logger.d.ts +1 -1
  149. package/dist/ui/messages/chrome.d.ts +27 -6
  150. package/dist/ui/messages/chrome.js +78 -12
  151. package/dist/ui/messages/commands.d.ts +71 -3
  152. package/dist/ui/messages/commands.js +98 -3
  153. package/dist/ui/messages/networkMessages.d.ts +50 -5
  154. package/dist/ui/messages/networkMessages.js +50 -6
  155. package/dist/ui/messages/session.d.ts +8 -0
  156. package/dist/ui/messages/session.js +10 -0
  157. package/dist/utils/async.d.ts +3 -2
  158. package/dist/utils/async.js +16 -3
  159. package/dist/utils/atomicFile.d.ts +2 -1
  160. package/dist/utils/atomicFile.js +5 -2
  161. package/dist/utils/directories.d.ts +41 -0
  162. package/dist/utils/directories.js +48 -0
  163. package/dist/utils/http.d.ts +11 -4
  164. package/dist/utils/http.js +5 -3
  165. package/package.json +18 -4
  166. /package/dist/{commands/dom → runtime/page}/screenshotResize.d.ts +0 -0
  167. /package/dist/{commands/dom → runtime/page}/screenshotResize.js +0 -0
@@ -2,52 +2,173 @@
2
2
  * The session's user agent in headless Chrome: regular Chrome's string and
3
3
  * client hints, so sites serve the page a user sees.
4
4
  */
5
+ import { readFileSync } from 'node:fs';
6
+ import os from 'node:os';
7
+ import { createLogger } from '../../ui/logging/index.js';
5
8
  import { getErrorMessage } from '../../utils/errors.js';
6
- /** Reads the client hints Chrome reports, renaming the HeadlessChrome brand */
7
- const USER_AGENT_METADATA_SCRIPT = `(async () => {
8
- const data = navigator.userAgentData;
9
- if (!data) return null;
10
- const values = await data.getHighEntropyValues(
11
- ['architecture', 'bitness', 'model', 'platformVersion', 'fullVersionList', 'wow64']
12
- );
13
- const rename = (list) => (list || []).map((entry) => ({
14
- brand: entry.brand.replace('HeadlessChrome', 'Google Chrome'),
15
- version: entry.version,
16
- }));
17
- return {
18
- brands: rename(values.brands),
19
- fullVersionList: rename(values.fullVersionList),
20
- platform: values.platform,
21
- platformVersion: values.platformVersion,
22
- architecture: values.architecture,
23
- model: values.model,
24
- mobile: values.mobile,
25
- bitness: values.bitness,
26
- wow64: values.wow64,
27
- };
28
- })()`;
9
+ const log = createLogger('session');
10
+ /** Characters Chrome picks from for its made-up ("GREASE") brand name */
11
+ const GREASE_CHARS = [' ', '(', ':', '-', '.', '/', ')', ';', '=', '?', '_'];
12
+ /** Versions Chrome picks from for its made-up brand */
13
+ const GREASE_VERSIONS = ['8', '99', '24'];
14
+ /** Positions of the made-up brand, Chromium and the browser brand, by major version */
15
+ const BRAND_ORDERS = [
16
+ [0, 1, 2],
17
+ [0, 2, 1],
18
+ [1, 0, 2],
19
+ [1, 2, 0],
20
+ [2, 0, 1],
21
+ [2, 1, 0],
22
+ ];
23
+ /** Client-hint platform for each `process.platform` Chrome runs on */
24
+ const HOST_PLATFORMS = {
25
+ darwin: 'macOS',
26
+ win32: 'Windows',
27
+ linux: 'Linux',
28
+ };
29
+ /** Client-hint platform for each platform token of the user agent string */
30
+ const USER_AGENT_PLATFORMS = [
31
+ [/Android/, 'Android'],
32
+ [/CrOS/, 'Chrome OS'],
33
+ [/Macintosh/, 'macOS'],
34
+ [/Windows/, 'Windows'],
35
+ [/Linux|X11/, 'Linux'],
36
+ ];
37
+ /**
38
+ * Chrome's brand list for a version, built the way Chrome builds it
39
+ * (`GenerateBrandVersionList` in Chromium's `user_agent_utils.cc`): a
40
+ * made-up brand, Chromium and the browser's brand, in an order and with a
41
+ * made-up name and version that depend on the major version.
42
+ *
43
+ * @param major - Major version (the seed)
44
+ * @param chromium - Chromium version to list
45
+ * @param browser - Browser brand and version
46
+ * @param greaseSuffix - Appended to the made-up version (`.0.0.0` in full versions)
47
+ * @returns Brand list
48
+ */
49
+ export function chromeBrandList(major, chromium, browser, greaseSuffix = '') {
50
+ const grease = {
51
+ brand: `Not${GREASE_CHARS[major % GREASE_CHARS.length]}A${GREASE_CHARS[(major + 1) % GREASE_CHARS.length]}Brand`,
52
+ version: `${GREASE_VERSIONS[major % GREASE_VERSIONS.length]}${greaseSuffix}`,
53
+ };
54
+ const [greaseAt, chromiumAt, browserAt] = BRAND_ORDERS[major % BRAND_ORDERS.length] ?? [0, 1, 2];
55
+ const list = [];
56
+ list[greaseAt] = grease;
57
+ list[chromiumAt] = { brand: 'Chromium', version: chromium };
58
+ list[browserAt] = browser;
59
+ return list;
60
+ }
61
+ /**
62
+ * The client hints regular Chrome sends, for the browser `Browser.getVersion`
63
+ * describes. The browser brand is Microsoft Edge when the user agent says
64
+ * `Edg/`, Google Chrome otherwise (Chromium and Chrome for Testing look the
65
+ * same over CDP). Edge's Chromium version is only known to the major
66
+ * version. The OS version, architecture and bitness are the host's when it
67
+ * runs the platform the user agent names, empty otherwise (a remote Chrome).
68
+ *
69
+ * @param version - `Browser.getVersion` product and user agent
70
+ * @param host - The daemon's machine
71
+ * @returns Metadata for `Emulation.setUserAgentOverride`
72
+ */
73
+ export function regularChromeMetadata(version, host) {
74
+ const fullVersion = /\/([\d.]+)/.exec(version.product)?.[1] ?? '';
75
+ const major = Number.parseInt(fullVersion, 10) || 0;
76
+ const isEdge = / Edg\//.test(version.userAgent);
77
+ const chromiumVersion = isEdge ? `${major}.0.0.0` : fullVersion;
78
+ const brand = isEdge ? 'Microsoft Edge' : 'Google Chrome';
79
+ const platform = USER_AGENT_PLATFORMS.find(([token]) => token.test(version.userAgent))?.[1] ?? host.platform;
80
+ const isHost = platform === host.platform;
81
+ return {
82
+ brands: chromeBrandList(major, String(major), { brand, version: String(major) }),
83
+ fullVersionList: chromeBrandList(major, chromiumVersion, { brand, version: fullVersion }, '.0.0.0'),
84
+ fullVersion,
85
+ platform,
86
+ platformVersion: isHost ? host.platformVersion : '',
87
+ architecture: isHost ? host.architecture : '',
88
+ bitness: isHost ? host.bitness : '',
89
+ model: '',
90
+ mobile: false,
91
+ wow64: false,
92
+ formFactors: ['Desktop'],
93
+ };
94
+ }
95
+ /** First Windows build of Windows 11, which client hints report as version 13 */
96
+ const WINDOWS_11_BUILD = 22000;
97
+ /**
98
+ * The OS version Chrome reports on Linux or Windows, from `os.release()`:
99
+ * the kernel version's first three numbers on Linux, and on Windows `13.0.0`
100
+ * for Windows 11 and `10.0.0` before it (Chrome reports a Windows API
101
+ * version there, not the OS build).
102
+ *
103
+ * @param platform - `process.platform`
104
+ * @param release - `os.release()`
105
+ * @returns OS version, or empty when unknown
106
+ */
107
+ export function releasePlatformVersion(platform, release) {
108
+ if (platform === 'win32') {
109
+ const build = Number(release.split('.')[2]);
110
+ if (!build)
111
+ return '';
112
+ return build >= WINDOWS_11_BUILD ? '13.0.0' : '10.0.0';
113
+ }
114
+ return /^\d+(\.\d+){0,2}/.exec(release)?.[0] ?? '';
115
+ }
116
+ /**
117
+ * The OS version as Chrome reports it: the macOS product version (from
118
+ * `SystemVersion.plist`, as `sw_vers` prints it), or
119
+ * {@link releasePlatformVersion} elsewhere.
120
+ *
121
+ * @returns OS version, or empty when unknown
122
+ */
123
+ function hostPlatformVersion() {
124
+ if (process.platform !== 'darwin')
125
+ return releasePlatformVersion(process.platform, os.release());
126
+ try {
127
+ const plist = readFileSync('/System/Library/CoreServices/SystemVersion.plist', 'utf8');
128
+ return /<key>ProductVersion<\/key>\s*<string>([\d.]+)<\/string>/.exec(plist)?.[1] ?? '';
129
+ }
130
+ catch (error) {
131
+ log.debug(`macOS version unknown: ${getErrorMessage(error)}`);
132
+ return '';
133
+ }
134
+ }
135
+ /**
136
+ * The daemon's machine as client hints describe it.
137
+ *
138
+ * @returns Platform, OS version, architecture and bitness
139
+ */
140
+ export function hostPlatform() {
141
+ const arch = process.arch;
142
+ return {
143
+ platform: HOST_PLATFORMS[process.platform] ?? '',
144
+ platformVersion: hostPlatformVersion(),
145
+ architecture: arch.startsWith('arm') ? 'arm' : 'x86',
146
+ bitness: arch === 'arm64' || arch === 'x64' ? '64' : '32',
147
+ };
148
+ }
29
149
  /**
30
150
  * Send the user agent and client hints of regular Chrome from headless
31
151
  * Chrome: sites serve "HeadlessChrome" a different page (or a bot
32
152
  * challenge), so the page would not be the one a user sees. Not for a
33
153
  * session emulating a phone, whose emulation sets a mobile user agent.
34
154
  *
155
+ * The client hints are built from `Browser.getVersion` and the host
156
+ * ({@link regularChromeMetadata}) rather than read from the page: the page is
157
+ * still `about:blank`, which has no `navigator.userAgentData`, and an
158
+ * override without metadata empties the client hints.
159
+ *
35
160
  * @param cdp - CDP connection
36
161
  * @param logger - Logger for failures (the session works without it)
162
+ * @param known - `Browser.getVersion` result, when the caller has it
37
163
  */
38
- export async function hideHeadlessUserAgent(cdp, logger) {
164
+ export async function hideHeadlessUserAgent(cdp, logger, known) {
39
165
  try {
40
- const { userAgent } = (await cdp.send('Browser.getVersion'));
41
- if (!userAgent.includes('HeadlessChrome'))
166
+ const version = known ?? (await cdp.send('Browser.getVersion'));
167
+ if (!version.userAgent.includes('HeadlessChrome'))
42
168
  return;
43
- const metadata = (await cdp.send('Runtime.evaluate', {
44
- expression: USER_AGENT_METADATA_SCRIPT,
45
- awaitPromise: true,
46
- returnByValue: true,
47
- }));
48
169
  await cdp.send('Emulation.setUserAgentOverride', {
49
- userAgent: userAgent.replace('HeadlessChrome', 'Chrome'),
50
- ...(metadata.result?.value ? { userAgentMetadata: metadata.result.value } : {}),
170
+ userAgent: version.userAgent.replace('HeadlessChrome', 'Chrome'),
171
+ userAgentMetadata: regularChromeMetadata(version, hostPlatform()),
51
172
  });
52
173
  }
53
174
  catch (error) {
@@ -5,6 +5,7 @@
5
5
  * (or `~/.bdg/sessions/<name>/` for a named session).
6
6
  * WHY: Single source of truth for file locations prevents path inconsistencies.
7
7
  */
8
+ import { type DirTrustKind } from '../utils/directories.js';
8
9
  /**
9
10
  * Session file paths relative to ~/.bdg/
10
11
  * Centralized definition for all session-related files.
@@ -123,12 +124,60 @@ export declare function getDaemonSocketPath(): string;
123
124
  /**
124
125
  * Ensure the session directory exists.
125
126
  *
126
- * Creates ~/.bdg/ if it doesn't exist. Safe to call multiple times (idempotent).
127
- * A path that cannot hold a directory is refused before `mkdir`, which
128
- * would spin on a pseudo-filesystem ({@link makeDirectory}).
127
+ * Creates it, and missing parents (the base directory, `sessions/`), with
128
+ * mode 0700. Safe to call multiple times (idempotent). A path that cannot
129
+ * hold a directory is refused before `mkdir`, which would spin on a
130
+ * pseudo-filesystem ({@link makeDirectory}).
129
131
  *
130
132
  * @throws Error if the directory cannot be created
131
133
  */
132
134
  export declare function ensureSessionDir(): void;
135
+ /**
136
+ * The session's downloads directory (whether or not it exists).
137
+ *
138
+ * @returns Absolute path, `<session dir>/downloads`
139
+ */
140
+ export declare function getSessionDownloadsDir(): string;
141
+ /**
142
+ * Ensure the session's downloads directory exists (mode 0700, like the
143
+ * session directory) and return it.
144
+ *
145
+ * @returns Absolute path, `<session dir>/downloads`
146
+ * @throws Error if the directory cannot be created, or a file is in its place
147
+ */
148
+ export declare function ensureSessionDownloadsDir(): string;
149
+ /** A session directory (or one above it) that cannot be trusted */
150
+ export interface UntrustedSessionDir {
151
+ /** The untrusted directory */
152
+ dir: string;
153
+ /** Why, e.g. `writable by others (mode 777)` */
154
+ reason: string;
155
+ kind: DirTrustKind;
156
+ /** bdg owns it by convention (`~/.bdg`, `sessions/`, `sessions/<name>`) */
157
+ bdgOwned: boolean;
158
+ }
159
+ /**
160
+ * Check that a session directory and the directories above it up to the base
161
+ * directory can be trusted before a daemon is started there or its socket is
162
+ * connected to: another user who can write to one of them could replace the
163
+ * socket (and receive every command) or plant files. Each existing directory
164
+ * must be a real directory (not a symlink), owned by the user, and not
165
+ * writable by others ({@link dirTrustProblem}); group write is accepted, since
166
+ * under umask 002 (per-user groups) older versions created `~/.bdg` 0775.
167
+ * The base directory may be a symlink whose target passes the same rule
168
+ * ({@link chainDirProblem}). A directory owned by another uid (a bind mount,
169
+ * `sudo -E`) is refused.
170
+ *
171
+ * Trusted directories bdg owns (the default `~/.bdg`, `sessions/` and named
172
+ * session directories) that group or others can still use are tightened to
173
+ * 0700; a base directory chosen with `$BDG_SESSION_DIR`, or a symlinked base
174
+ * (opened without following links), is never changed.
175
+ * Missing paths are skipped (as is a path through a file, which fails on its
176
+ * own): {@link ensureSessionDir} creates them 0700.
177
+ *
178
+ * @param dir - Session directory (default: the selected session's)
179
+ * @returns The first untrusted directory and why, or null
180
+ */
181
+ export declare function secureSessionDir(dir?: string): UntrustedSessionDir | null;
133
182
  export {};
134
183
  //# sourceMappingURL=paths.d.ts.map
@@ -9,7 +9,8 @@ import * as fs from 'fs';
9
9
  import * as os from 'os';
10
10
  import * as path from 'path';
11
11
  import { createLogger, logDebugError } from '../ui/logging/index.js';
12
- import { makeDirectory } from '../utils/directories.js';
12
+ import { dirTrustProblem, makeDirectory, } from '../utils/directories.js';
13
+ import { getErrorMessage } from '../utils/errors.js';
13
14
  const log = createLogger('session');
14
15
  /**
15
16
  * Session file paths relative to ~/.bdg/
@@ -57,12 +58,21 @@ export const SESSION_STATE_FILES = [
57
58
  * @returns Full path to the base session directory
58
59
  */
59
60
  export function getSessionBaseDir() {
60
- const override = process.env[SESSION_DIR_OVERRIDE_ENV];
61
- if (override && override.trim().length > 0) {
61
+ const override = sessionDirOverride();
62
+ if (override !== null) {
62
63
  return path.isAbsolute(override) ? override : path.resolve(override);
63
64
  }
64
65
  return path.join(os.homedir(), '.bdg');
65
66
  }
67
+ /**
68
+ * The base session directory the user chose with `$BDG_SESSION_DIR`.
69
+ *
70
+ * @returns The variable's value, or null when unset or blank (`~/.bdg`)
71
+ */
72
+ function sessionDirOverride() {
73
+ const override = process.env[SESSION_DIR_OVERRIDE_ENV];
74
+ return override && override.trim().length > 0 ? override : null;
75
+ }
66
76
  /**
67
77
  * The selected session name (`--session` / `BDG_SESSION`), lower-cased:
68
78
  * session names are case-insensitive.
@@ -163,16 +173,178 @@ export function getSessionFilePath(fileType) {
163
173
  export function getDaemonSocketPath() {
164
174
  return getSessionFilePath('DAEMON_SOCKET');
165
175
  }
176
+ /** Mode of the session directories bdg creates: only the user can enter them */
177
+ const PRIVATE_DIR_MODE = 0o700;
178
+ /** Permission bits that give group or others any access */
179
+ const GROUP_OTHER_ACCESS = 0o077;
166
180
  /**
167
181
  * Ensure the session directory exists.
168
182
  *
169
- * Creates ~/.bdg/ if it doesn't exist. Safe to call multiple times (idempotent).
170
- * A path that cannot hold a directory is refused before `mkdir`, which
171
- * would spin on a pseudo-filesystem ({@link makeDirectory}).
183
+ * Creates it, and missing parents (the base directory, `sessions/`), with
184
+ * mode 0700. Safe to call multiple times (idempotent). A path that cannot
185
+ * hold a directory is refused before `mkdir`, which would spin on a
186
+ * pseudo-filesystem ({@link makeDirectory}).
172
187
  *
173
188
  * @throws Error if the directory cannot be created
174
189
  */
175
190
  export function ensureSessionDir() {
176
- makeDirectory(getSessionDir());
191
+ makeDirectory(getSessionDir(), PRIVATE_DIR_MODE);
192
+ }
193
+ /** Subdirectory of a session directory that a launched Chrome downloads into */
194
+ const DOWNLOADS_DIR = 'downloads';
195
+ /**
196
+ * The session's downloads directory (whether or not it exists).
197
+ *
198
+ * @returns Absolute path, `<session dir>/downloads`
199
+ */
200
+ export function getSessionDownloadsDir() {
201
+ return path.join(getSessionDir(), DOWNLOADS_DIR);
202
+ }
203
+ /**
204
+ * Ensure the session's downloads directory exists (mode 0700, like the
205
+ * session directory) and return it.
206
+ *
207
+ * @returns Absolute path, `<session dir>/downloads`
208
+ * @throws Error if the directory cannot be created, or a file is in its place
209
+ */
210
+ export function ensureSessionDownloadsDir() {
211
+ const dir = getSessionDownloadsDir();
212
+ makeDirectory(dir, PRIVATE_DIR_MODE);
213
+ if (!fs.statSync(dir).isDirectory()) {
214
+ throw Object.assign(new Error(`${dir} is a file`), { code: 'ENOTDIR' });
215
+ }
216
+ return dir;
217
+ }
218
+ /** Session directories need not keep group write out (umask 002 made them 0775) */
219
+ const SESSION_DIR_TRUST = { allowGroupWrite: true };
220
+ /**
221
+ * Directories whose trust a session directory depends on: the base
222
+ * directory and each directory from it down to the session directory
223
+ * (`<base>`, `<base>/sessions`, `<base>/sessions/<name>`), or the directory
224
+ * alone when it is outside the base.
225
+ *
226
+ * @param dir - Session directory
227
+ * @returns Directories, outermost first
228
+ */
229
+ function sessionDirChain(dir) {
230
+ const base = getSessionBaseDir();
231
+ const relative = path.relative(base, dir);
232
+ if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
233
+ return [{ dir, bdgOwned: false, isBase: false }];
234
+ }
235
+ const chain = [{ dir: base, bdgOwned: sessionDirOverride() === null, isBase: true }];
236
+ let current = base;
237
+ for (const part of relative.split(path.sep).filter(Boolean)) {
238
+ current = path.join(current, part);
239
+ chain.push({ dir: current, bdgOwned: true, isBase: false });
240
+ }
241
+ return chain;
242
+ }
243
+ /**
244
+ * Remove group and other access from a directory of the user's (best
245
+ * effort). The directory is opened without following a symlink and changed
246
+ * through that descriptor, so a path swapped after the trust check is not
247
+ * affected.
248
+ *
249
+ * @param dir - Trusted directory
250
+ */
251
+ function tightenDir(dir) {
252
+ if (process.platform === 'win32')
253
+ return;
254
+ let fd;
255
+ try {
256
+ const { O_RDONLY, O_DIRECTORY, O_NOFOLLOW } = fs.constants;
257
+ fd = fs.openSync(dir, O_RDONLY | O_DIRECTORY | O_NOFOLLOW);
258
+ const stat = fs.fstatSync(fd);
259
+ if (stat.uid !== process.getuid?.() || (stat.mode & GROUP_OTHER_ACCESS) === 0)
260
+ return;
261
+ fs.fchmodSync(fd, PRIVATE_DIR_MODE);
262
+ log.debug(`Session directory ${dir} restricted to mode 700`);
263
+ }
264
+ catch (error) {
265
+ log.debug(`Session directory ${dir} not restricted: ${getErrorMessage(error)}`);
266
+ }
267
+ finally {
268
+ if (fd !== undefined)
269
+ fs.closeSync(fd);
270
+ }
271
+ }
272
+ /**
273
+ * Why a directory of the chain cannot be trusted. The base directory alone
274
+ * may be a symlink (`~/.bdg` kept with dotfiles or on another disk): its
275
+ * target is checked instead. `sessions/` and session directories must be
276
+ * real directories.
277
+ *
278
+ * @param entry - Directory of the chain
279
+ * @returns The problem, or null when it can be trusted
280
+ * @throws Error from `lstat`/`realpath` (e.g. `ENOENT`)
281
+ */
282
+ function chainDirProblem(entry) {
283
+ const problem = dirTrustProblem(entry.dir, SESSION_DIR_TRUST);
284
+ if (problem?.kind !== 'symlink' || !entry.isBase)
285
+ return problem;
286
+ const target = fs.realpathSync(entry.dir);
287
+ const targetProblem = dirTrustProblem(target, SESSION_DIR_TRUST);
288
+ return (targetProblem && {
289
+ ...targetProblem,
290
+ reason: `it links to ${target}, which is ${targetProblem.reason}`,
291
+ });
292
+ }
293
+ /**
294
+ * Accept a trusted directory: tighten it to 0700 when bdg owns it, leave a
295
+ * directory the user chose as it is (noted in the debug log when group or
296
+ * others can use it).
297
+ *
298
+ * @param entry - Trusted directory
299
+ */
300
+ function acceptTrustedDir(entry) {
301
+ if (entry.bdgOwned) {
302
+ tightenDir(entry.dir);
303
+ return;
304
+ }
305
+ const mode = fs.statSync(entry.dir, { throwIfNoEntry: false })?.mode ?? 0;
306
+ if ((mode & GROUP_OTHER_ACCESS) === 0)
307
+ return;
308
+ log.debug(`Session directory ${entry.dir} is open to group or others; left as is (chosen with ${SESSION_DIR_OVERRIDE_ENV})`);
309
+ }
310
+ /**
311
+ * Check that a session directory and the directories above it up to the base
312
+ * directory can be trusted before a daemon is started there or its socket is
313
+ * connected to: another user who can write to one of them could replace the
314
+ * socket (and receive every command) or plant files. Each existing directory
315
+ * must be a real directory (not a symlink), owned by the user, and not
316
+ * writable by others ({@link dirTrustProblem}); group write is accepted, since
317
+ * under umask 002 (per-user groups) older versions created `~/.bdg` 0775.
318
+ * The base directory may be a symlink whose target passes the same rule
319
+ * ({@link chainDirProblem}). A directory owned by another uid (a bind mount,
320
+ * `sudo -E`) is refused.
321
+ *
322
+ * Trusted directories bdg owns (the default `~/.bdg`, `sessions/` and named
323
+ * session directories) that group or others can still use are tightened to
324
+ * 0700; a base directory chosen with `$BDG_SESSION_DIR`, or a symlinked base
325
+ * (opened without following links), is never changed.
326
+ * Missing paths are skipped (as is a path through a file, which fails on its
327
+ * own): {@link ensureSessionDir} creates them 0700.
328
+ *
329
+ * @param dir - Session directory (default: the selected session's)
330
+ * @returns The first untrusted directory and why, or null
331
+ */
332
+ export function secureSessionDir(dir = getSessionDir()) {
333
+ for (const entry of sessionDirChain(dir)) {
334
+ let problem;
335
+ try {
336
+ problem = chainDirProblem(entry);
337
+ }
338
+ catch (error) {
339
+ const code = error.code;
340
+ if (code === 'ENOENT' || code === 'ENOTDIR')
341
+ continue;
342
+ problem = { reason: getErrorMessage(error), kind: 'not-directory' };
343
+ }
344
+ if (problem !== null)
345
+ return { dir: entry.dir, ...problem, bdgOwned: entry.bdgOwned };
346
+ acceptTrustedDir(entry);
347
+ }
348
+ return null;
177
349
  }
178
350
  //# sourceMappingURL=paths.js.map
@@ -30,14 +30,6 @@ export declare function readPortFile(portPath: string): number | null;
30
30
  * @returns `$BDG_PORT_REGISTRY_DIR`, else `<os temp dir>/bdg-ports-<uid>`
31
31
  */
32
32
  export declare function getPortRegistryDir(): string;
33
- /**
34
- * Whether a path is a directory the current user can trust: a real directory
35
- * (not a symlink), owned by the user, not writable by group or others.
36
- *
37
- * @param dir - Directory
38
- * @returns Why it cannot be trusted, or null if it can
39
- */
40
- export declare function untrustedDirReason(dir: string): string | null;
41
33
  /**
42
34
  * Session directories other than the selected one that bdg knows of: those of
43
35
  * this base directory, and those of any base directory found in the
@@ -20,7 +20,7 @@ import * as path from 'path';
20
20
  import { getSessionBaseDir, getSessionDir, listSessionDirs, sessionFilePathIn, } from './paths.js';
21
21
  import { createLogger, logDebugError } from '../ui/logging/index.js';
22
22
  import { delay } from '../utils/async.js';
23
- import { makeDirectory } from '../utils/directories.js';
23
+ import { makeDirectory, untrustedDirReason } from '../utils/directories.js';
24
24
  const log = createLogger('session');
25
25
  /** Lock file guarding port selection, in the port registry (or base session) directory */
26
26
  const PORT_LOCK_FILE = 'port.lock';
@@ -33,8 +33,6 @@ const LOCK_WAIT_MS = 5000;
33
33
  /** Lock age after which its holder is assumed dead */
34
34
  const STALE_LOCK_MS = 10000;
35
35
  const LOCK_POLL_MS = 25;
36
- /** Permission bits that let other users write */
37
- const GROUP_OTHER_WRITE = 0o022;
38
36
  /**
39
37
  * Read a port number from a `port.txt` file.
40
38
  *
@@ -64,25 +62,6 @@ export function getPortRegistryDir() {
64
62
  const uid = process.getuid?.();
65
63
  return path.join(os.tmpdir(), uid === undefined ? 'bdg-ports' : `bdg-ports-${uid}`);
66
64
  }
67
- /**
68
- * Whether a path is a directory the current user can trust: a real directory
69
- * (not a symlink), owned by the user, not writable by group or others.
70
- *
71
- * @param dir - Directory
72
- * @returns Why it cannot be trusted, or null if it can
73
- */
74
- export function untrustedDirReason(dir) {
75
- const stat = fs.lstatSync(dir);
76
- if (!stat.isDirectory())
77
- return 'not a directory';
78
- const uid = process.getuid?.();
79
- if (uid !== undefined && stat.uid !== uid)
80
- return `owned by uid ${stat.uid}`;
81
- if (process.platform !== 'win32' && (stat.mode & GROUP_OTHER_WRITE) !== 0) {
82
- return `writable by others (mode ${(stat.mode & 0o777).toString(8)})`;
83
- }
84
- return null;
85
- }
86
65
  /**
87
66
  * The port registry directory, created (mode 0700) if missing, if it can be
88
67
  * trusted (see {@link untrustedDirReason}).
@@ -11,8 +11,10 @@ import { type SessionDirEntry } from './paths.js';
11
11
  * `unresponsive`), died and left its Chrome running (`crashed`) or only
12
12
  * files (`stale`), or exited after the session ended without `bdg stop`
13
13
  * (`ended`: Chrome crashed or was closed, the page was closed, `--timeout`).
14
+ * `untrusted`: something listens on its socket, but its directory is not
15
+ * safe to use, so it is not asked.
14
16
  */
15
- export type RunningSessionState = 'active' | 'starting' | 'ending' | 'unresponsive' | 'crashed' | 'stale' | 'ended';
17
+ export type RunningSessionState = 'active' | 'starting' | 'ending' | 'unresponsive' | 'crashed' | 'stale' | 'ended' | 'untrusted';
16
18
  /**
17
19
  * One session in the list.
18
20
  */
@@ -33,6 +35,8 @@ export interface RunningSessionInfo {
33
35
  endReason?: UnexpectedEndReason;
34
36
  /** When an `ended` session ended (epoch ms) */
35
37
  endedAt?: number;
38
+ /** Why an `untrusted` session's directory is not safe to use */
39
+ untrusted?: string;
36
40
  }
37
41
  /**
38
42
  * Summarize a daemon's status response.
@@ -7,7 +7,7 @@ import { getStatus } from '../ipc/client.js';
7
7
  import { isSessionChrome, readLiveDaemonPid } from './cleanup/staleSession.js';
8
8
  import { probeDaemonSocket } from './daemonSocket.js';
9
9
  import { readLastSessionEnd } from './lastSession.js';
10
- import { SESSION_STATE_FILES, getNamedSessionDir, listSessionDirs, sessionFilePathIn, } from './paths.js';
10
+ import { SESSION_STATE_FILES, getNamedSessionDir, listSessionDirs, secureSessionDir, sessionFilePathIn, } from './paths.js';
11
11
  import { readPidFromFile } from './pid.js';
12
12
  import { readPortFile } from './portClaims.js';
13
13
  import { isValidSessionName, normalizeSessionName } from './sessionName.js';
@@ -48,6 +48,10 @@ async function describeSession(entry) {
48
48
  const probe = await probeDaemonSocket(socketPath);
49
49
  if (probe !== 'alive')
50
50
  return describeWithoutSocket(entry, probe);
51
+ const untrusted = secureSessionDir(dir);
52
+ if (untrusted) {
53
+ return { name, state: 'untrusted', untrusted: `${untrusted.dir}: ${untrusted.reason}` };
54
+ }
51
55
  try {
52
56
  const response = await getStatus(socketPath);
53
57
  if (response.status === 'ok' && response.data)