diff --git a/docs/specs/alert.md b/docs/specs/alert.md index 0faa58b4..839b9c88 100644 --- a/docs/specs/alert.md +++ b/docs/specs/alert.md @@ -147,7 +147,7 @@ In VS Code the `AlertManager` lives in the extension host while `dor` control re Rules: -- The key is `commandArgv0(rawCommandLine)` in `lib/src/lib/terminal-state.ts`: take everything before the first pipeline/compound boundary, skip leading `VAR=value` assignments and a leading `env`, then reduce argv[0] to its basename. `claude`, `/usr/local/bin/claude --resume`, and `FOO=1 env BAR=2 claude` all key on `claude`. `foo | claude` keys on `foo`, matching what bash's `DEBUG` trap reports. +- The key is `commandArgv0(rawCommandLine)` in `lib/src/lib/terminal-state.ts`: take everything before the first pipeline/compound boundary, skip leading `VAR=value` assignments and a leading `env`, then reduce argv[0] to its basename, minus any launcher suffix (`docs/specs/terminal-state.md`). `claude`, `/usr/local/bin/claude --resume`, and `FOO=1 env BAR=2 claude` all key on `claude`. `foo | claude` keys on `foo`, matching what bash's `DEBUG` trap reports. - Every command boundary — `commandStart`, `commandFinish`, `promptStart`, `promptEnd`, and PTY exit — resets the detector, so one command's output history can never leak into the next one's reading. Editing the rule set re-derives WATCHING across every live Session immediately, and because the detector kept running underneath, enabling a rule mid-command shows what that command is doing *right now* rather than a fresh `NOTHING_TO_SHOW`. - A WATCHING ring outlives the command that raised it. Watching switches off the moment the watched command exits, which is usually the same moment the ring was raised, so the ring and its originating command key are held in the Session entry (`watchingRingingCommand`). - Removing a rule is the one thing that *does* silence a WATCHING ring: it is the user saying "stop alerting on this". The latched originating key makes this work after the command has exited and watching is already off. A command merely ending never clears the ring. diff --git a/docs/specs/terminal-state.md b/docs/specs/terminal-state.md index 8afa31b8..2164bd0e 100644 --- a/docs/specs/terminal-state.md +++ b/docs/specs/terminal-state.md @@ -98,6 +98,8 @@ The parser accepts both BEL and ST terminators and handles split chunks. Support - `commandFinish` moves `currentCommand` to `lastCommand`, stores `finishedAt`/`exitCode`, snapshots the latest in-run OSC 0/2/9 title into `lastCommand.finalTerminalTitle` (titles older than `startedAt` or younger than `finishedAt` are excluded), clears `currentCommand`, and sets `{ kind: "finished", exitCode }`. With no `currentCommand` it only sets the activity — it never invents a `lastCommand`. - `title` updates `title` and the per-source entry in `titleCandidates`. Later OSC title events do not erase earlier user, shell, or notification candidates from other sources. +Command-line tokenizing is dialect-free. `\` escapes exactly the set `shellEscapePosix` writes (`POSIX_ESCAPABLE` in `lib/src/lib/posix-escape.ts`, both halves pinned by `terminal-state.test.ts`), so POSIX escapes keep their meaning while a native Windows program path keeps the separators the basename step splits on. A leading `&` is PowerShell's call operator, never a POSIX background suffix, so it is dropped rather than read as a boundary. An unquoted Windows path containing spaces stays split — which token ends the program name is undecidable without the filesystem. **A launcher suffix is not part of a program's name**: `npm.cmd` and `C:\tools\claude.exe` are `npm` and `claude` for the header, the WATCHING key, and the bell tooltip alike, so PATHEXT's spellings of one program cannot become two rules. Accepted: `foo.bat` and `foo.exe` in one directory cannot be watched separately. + ### Keystroke fallback For shells without OSC 133/633 integration, the command is read from what is on screen rather than reconstructed from keystrokes. diff --git a/lib/src/lib/posix-escape.ts b/lib/src/lib/posix-escape.ts new file mode 100644 index 00000000..72d78633 --- /dev/null +++ b/lib/src/lib/posix-escape.ts @@ -0,0 +1,20 @@ +/** + * The one definition of "characters a POSIX shell backslash-escapes", shared by + * the two halves that have to agree about it: + * + * - `shellEscapePosix` (`shell-escape.ts`) *writes* them, backslash-escaping + * each one so a dropped path pastes as a path rather than as opaque text. + * - `tokenizeCommand` (`terminal-state.ts`) *reads* them back, treating `\` + * before anything else as a literal path separator so a native Windows + * program path survives to the basename split. + * + * They disagreed once, about `~`, and a path Dormouse itself escaped rendered + * with a stray backslash in the pane header. `terminal-state.test.ts` -> + * "command tokenizer dialects" pins both directions character by character. + * + * Its own module because `terminal-state.ts` is bundled into the VS Code + * extension host, which resolves the `dor/*` path `shell-escape.ts` imports only + * through a tsconfig mapping its vitest run does not read — so this file must + * stay dependency-free. + */ +export const POSIX_ESCAPABLE = /[ \t!"#$&'()*;<>?[\\\]`{|}~]/; diff --git a/lib/src/lib/shell-escape.ts b/lib/src/lib/shell-escape.ts index 7b2bdc1b..ef826bb2 100644 --- a/lib/src/lib/shell-escape.ts +++ b/lib/src/lib/shell-escape.ts @@ -1,10 +1,11 @@ import { quotePowerShellArg, type ShellCommandKind } from 'dor/commands/shell-quote'; +import { POSIX_ESCAPABLE } from './posix-escape'; // Matches macOS Terminal's drag-and-drop format: backslash-escape each shell // metacharacter instead of wrapping in quotes. TUIs like `claude` recognize // backslash-escaped tokens as filesystem paths where a single-quoted whole // path gets treated as opaque pasted text. -const POSIX_UNSAFE = /([ \t!"#$&'()*;<>?[\\\]`{|}~])/g; +const POSIX_UNSAFE = new RegExp(`(${POSIX_ESCAPABLE.source})`, 'g'); const POSIX_NEEDS_QUOTES = /[\n\r]/; export function shellEscapePosix(input: string): string { diff --git a/lib/src/lib/terminal-state.test.ts b/lib/src/lib/terminal-state.test.ts index d59572ad..0f12854e 100644 --- a/lib/src/lib/terminal-state.test.ts +++ b/lib/src/lib/terminal-state.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from 'vitest'; +import { POSIX_ESCAPABLE } from './posix-escape'; +import { shellEscapePosix } from './shell-escape'; import { + commandArgv0, createTerminalPaneState, cwdDisplay, cwdFromManualPath, @@ -266,6 +269,15 @@ describe('command title summarizer', () => { expect(summarizeCommandLine('ssh prod-box')).toBe('ssh prod-box'); }); + // One name per program: the launcher suffix is dropped everywhere, so the + // header reads the same name as the WATCHING rule row and the bell tooltip. + it('reads a Windows launcher as the program it launches', () => { + expect(summarizeCommandLine('vim.exe notes.txt')).toBe('vim'); + expect(summarizeCommandLine('cargo.exe watch -x test')).toBe('cargo watch -x test'); + expect(summarizeCommandLine('C:\\tools\\nodejs\\npm.cmd')).toBe('npm'); + expect(summarizeCommandLine('C:\\tools\\nodejs\\npm.cmd run dev')).toBe('npm run dev'); + }); + it('keeps pipelines and compound commands recognizable', () => { expect(summarizeCommandLine('cat package.json | jq .name')).toBe('cat package.json | ...'); expect(summarizeCommandLine('cd lib && pnpm test')).toBe('cd lib ...'); @@ -273,6 +285,60 @@ describe('command title summarizer', () => { }); }); +describe('command tokenizer dialects', () => { + // A backslash is a path separator unless it precedes something a shell really + // escapes, so both dialects reduce to the bare program name. + it.each([ + // Windows: absolute paths, launchers, a quoted path with spaces. + ['C:\\tools\\dor.cmd tool storybook', 'dor', 'dor tool storybook'], + ['C:\\Users\\me\\.claude\\local\\claude', 'claude', 'claude'], + ['"C:\\Program Files\\nodejs\\npm.cmd" run dev', 'npm', 'npm run dev'], + ['\\\\build\\share\\tools\\claude.exe --print', 'claude', 'claude --print'], + ['FOO=1 "C:\\Program Files\\nodejs\\npm.cmd" run dev', 'npm', 'npm run dev'], + // PowerShell's call operator, the only way that shell runs a quoted path. + // Without the leading-`&` skip it reads as a boundary and argv0 is null. + ['& "C:\\Program Files\\nodejs\\npm.cmd" run dev', 'npm', 'npm run dev'], + ['& C:\\tools\\dor.cmd tool storybook', 'dor', 'dor tool storybook'], + // POSIX escapes keep their meaning. + ['/opt/my\\ tools/claude --print', 'claude', 'claude --print'], + ['grep \\*.ts src', 'grep', 'grep *.ts src'], + ['echo a\\\\b', 'echo', 'echo a\\b'], + ])('reduces %j to %j / %j', (raw, argv0, summary) => { + expect(commandArgv0(raw)).toBe(argv0); + expect(summarizeCommandLine(raw)).toBe(summary); + }); + + // An unquoted Windows path with spaces is undecidable without probing the + // filesystem — `A\B C\D.cmd` is equally `A\B` plus an argument — so the + // tokenizer splits it and argv0 misses rather than naming the wrong program. + it('leaves an unquoted Windows path with spaces split', () => { + expect(commandArgv0('C:\\Program Files\\nodejs\\npm.cmd run dev')).toBe('Program'); + expect(commandArgv0('"C:\\Program Files\\Git\\bin\\bash" scripts\\bootstrap.cmd')).toBe('bash'); + }); + + it('pins the ordinary POSIX argv[0] escape cost of dialect-free tokenizing', () => { + expect(commandArgv0('foo\\-bar')).toBe('-bar'); + }); + + // `POSIX_ESCAPABLE` is `shellEscapePosix`'s set; the tokenizer unescapes it. + // The two halves must name the same characters or a path Dormouse escaped for + // a drag-and-drop paste renders with stray backslashes in the pane header. + const ESCAPABLE = ` \t!"#$&'()*;<>?[]\`{|}~\\`; + + it('is exactly the set spelled out here, so a change to it lands in this file', () => { + // Both directions, so neither a new nor a dropped member slips through. + expect(Array.from(ESCAPABLE).filter((char) => !POSIX_ESCAPABLE.test(char))).toEqual([]); + const printable = Array.from({ length: 95 }, (_, i) => String.fromCharCode(32 + i)); + expect(printable.filter((char) => POSIX_ESCAPABLE.test(char)).join('')).toBe( + Array.from(ESCAPABLE).filter((char) => char !== '\t').sort().join(''), + ); + }); + + it.each(Array.from(ESCAPABLE))('round-trips %j out of shellEscapePosix', (char) => { + expect(summarizeCommandLine(`cat ${shellEscapePosix(`a${char}b`)}`)).toBe(`cat a${char}b`); + }); +}); + describe('header and grouping derivation', () => { it('uses for terminals without a foreground command', () => { const pane = createTerminalPaneState({ cwd: cwdFromManualPath('/repo/app', 1)!, activity: { kind: 'editing' } }); diff --git a/lib/src/lib/terminal-state.ts b/lib/src/lib/terminal-state.ts index 862c7bc1..b6be4766 100644 --- a/lib/src/lib/terminal-state.ts +++ b/lib/src/lib/terminal-state.ts @@ -1,3 +1,5 @@ +import { POSIX_ESCAPABLE } from './posix-escape'; + export type CwdSource = 'osc7' | 'osc9_9' | 'osc633' | 'osc1337' | 'process' | 'manual'; export type PathKind = 'posix' | 'windows' | 'unknown'; @@ -381,13 +383,20 @@ export function summarizeCommandLine(raw: string): string { * all yield `claude`; `foo | claude` yields `foo`. Returns null when the line * holds no runnable word. * + * A Windows launcher suffix is not part of the name: `C:\tools\claude.exe`, + * `npm.cmd` and `build.ps1` yield `claude`, `npm` and `build`. `.exe` / `.cmd` + * is how one program spells itself when PATHEXT resolves it, so keeping the + * suffix would leave `npm` and `npm.cmd` as two rules for one program — the + * miss this whole path exists to close. Accepted: `foo.bat` and `foo.exe` in + * one directory cannot be watched separately. + * * This is the key WATCHING rules are stored under — see `docs/specs/alert.md`. */ export function commandArgv0(raw: string): string | null { const commandTokens = takePrimaryCommandTokens(tokenizeCommand(raw.trim())); const command = commandTokens[0]; if (!command) return null; - return command.split(/[\\/]/).pop() || null; + return commandProgramName(command) || null; } export interface ResolvedCommandStart { @@ -782,6 +791,23 @@ function withRequiredHostPrefixes( return result; } +/** + * Split a command line into words, honoring quotes, POSIX backslash escapes, + * and the pipeline/compound separators `| || && ; &`, which are emitted as + * their own tokens. + * + * A `\` escapes exactly the `POSIX_ESCAPABLE` set (`foo\ bar` is one token, + * `\*.ts` passes a literal glob, and a path Dormouse escaped for paste reads + * back as itself); before anything else it is a literal, so a native Windows + * program path survives tokenizing intact and `commandProgramName` still has + * separators to split on. Two accepted costs of one dialect-free set: a Windows + * segment that starts with a metacharacter (`C:\$Recycle.Bin`) still loses its + * separator, and a POSIX escape of an ordinary character (`grep \-v`) keeps a + * backslash bash would drop. Outside argv[0] both costs are display-only. Inside + * it, the retained POSIX backslash becomes a basename separator (`foo\-bar` -> + * `-bar`), while an eaten Windows separator leaves `C:\tools\$claude.exe` + * keyed as `tools$claude.exe`. + */ function tokenizeCommand(input: string): string[] { const tokens: string[] = []; let current = ''; @@ -803,7 +829,12 @@ function tokenizeCommand(input: string): string[] { continue; } if (char === '\\' && quote !== "'") { - escaping = true; + const next = input[i + 1]; + if (next !== undefined && POSIX_ESCAPABLE.test(next)) { + escaping = true; + continue; + } + current += char; continue; } if (quote) { @@ -844,8 +875,13 @@ function tokenizeCommand(input: string): string[] { } function takePrimaryCommandTokens(tokens: string[]): string[] { - const firstBoundary = tokens.findIndex((token) => token === '|' || token === '&&' || token === '||' || token === ';' || token === '&'); - const command = (firstBoundary === -1 ? tokens : tokens.slice(0, firstBoundary)).filter(Boolean); + // PowerShell's call operator. `& "C:\Program Files\nodejs\npm.cmd" run dev` + // is the only way that shell runs a quoted program path, and a leading `&` is + // never a POSIX background suffix, so drop it rather than read it as a + // boundary that leaves no command at all. + const words = tokens[0] === '&' ? tokens.slice(1) : tokens; + const firstBoundary = words.findIndex((token) => token === '|' || token === '&&' || token === '||' || token === ';' || token === '&'); + const command = (firstBoundary === -1 ? words : words.slice(0, firstBoundary)).filter(Boolean); let index = 0; while (isEnvAssignment(command[index])) index += 1; if (command[index] === 'env') { @@ -859,19 +895,37 @@ function isEnvAssignment(token: string | undefined): boolean { return !!token && /^[A-Za-z_][A-Za-z0-9_]*=/.test(token); } +/** A path reduced to its last segment, in either dialect. */ +function commandBasename(command: string): string { + return command.replace(/^.*[\\/]/, ''); +} + +/** PATHEXT's spellings of one program. Exported for `watched-commands.ts`, + * which drops a stored key ending in one: `commandArgv0` cannot produce one. */ +export const WINDOWS_EXECUTABLE_SUFFIX = /\.(?:exe|cmd|bat|com|ps1)$/i; + +/** + * argv[0] reduced to the one name a program answers to: no path, no launcher + * suffix. The single answer to "which program is this", so the header, the + * WATCHING rule row and the bell tooltip cannot disagree about it. + */ +function commandProgramName(command: string): string { + return commandBasename(command).replace(WINDOWS_EXECUTABLE_SUFFIX, ''); +} + function commandTitleTokens(tokens: string[]): string[] { const command = tokens[0]; if (!command) return []; - const basename = command.split(/[\\/]/).pop() ?? command; + const program = commandProgramName(command); const rest = tokens.slice(1); - if (basename === 'npm' && rest[0] === 'run') return [basename, ...rest.slice(0, 2)]; - if (basename === 'pnpm' || basename === 'yarn' || basename === 'bun') return [basename, ...rest.slice(0, 2)]; - if (basename === 'docker' && rest[0] === 'compose') return [basename, ...rest.slice(0, 2)]; - if (basename === 'cargo' && rest[0] === 'watch') return [basename, ...rest.slice(0, 3)]; - if (basename === 'ssh') return [basename, ...rest.slice(0, 1)]; - if (basename === 'vim' || basename === 'nvim' || basename === 'vi' || basename === 'pytest') return [basename]; - return [basename, ...rest.slice(0, 2)]; + if (program === 'npm' && rest[0] === 'run') return [program, ...rest.slice(0, 2)]; + if (program === 'pnpm' || program === 'yarn' || program === 'bun') return [program, ...rest.slice(0, 2)]; + if (program === 'docker' && rest[0] === 'compose') return [program, ...rest.slice(0, 2)]; + if (program === 'cargo' && rest[0] === 'watch') return [program, ...rest.slice(0, 3)]; + if (program === 'ssh') return [program, ...rest.slice(0, 1)]; + if (program === 'vim' || program === 'nvim' || program === 'vi' || program === 'pytest') return [program]; + return [program, ...rest.slice(0, 2)]; } function truncateCommandTitle(title: string): string { @@ -943,9 +997,11 @@ const GENERIC_PROCESS_TITLE_NAMES = new Set([ function isGenericProcessTitle(title: string): boolean { const trimmed = title.trim(); if (!trimmed) return false; - const basename = trimmed.split(/[\\/]/).pop() ?? trimmed; + // Basename, not program name: the suffix is the evidence this test is looking + // for, so stripping it would leave every `.exe` title indistinguishable. + const basename = commandBasename(trimmed); if (/\s/.test(basename)) return false; // carries arguments/description → meaningful - if (/\.(?:exe|com|bat|cmd|ps1)$/i.test(basename)) return true; // bare executable path + if (WINDOWS_EXECUTABLE_SUFFIX.test(basename)) return true; // bare executable path return GENERIC_PROCESS_TITLE_NAMES.has(basename.toLowerCase()); // bare shell/interpreter name } diff --git a/lib/src/lib/watched-commands.test.ts b/lib/src/lib/watched-commands.test.ts index 1f208f4f..ec44a8ac 100644 --- a/lib/src/lib/watched-commands.test.ts +++ b/lib/src/lib/watched-commands.test.ts @@ -47,18 +47,42 @@ describe('commandArgv0', () => { expect(commandArgv0(raw)).toBeNull(); }); - it('mangles an unquoted native Windows path, and that is a known limitation', () => { - // The shared tokenizer reads `\` as a POSIX escape, so backslash separators - // are eaten before the basename split can see them — `summarizeCommandLine` - // has always had the same blind spot. Harmless in practice: the shells that - // report a command line (pwsh, Git Bash, WSL) send either a bare program - // name or a POSIX path, and the mangling is at least stable, so a rule keyed - // on it still matches itself. - expect(commandArgv0('C:\\tools\\claude.exe --print')).toBe('C:toolsclaude.exe'); + it('reduces a native Windows path to the bare name a rule is stored under', () => { + // The tokenizer's dialect handling is pinned in `terminal-state.test.ts`. + expect(commandArgv0('C:\\Users\\me\\.claude\\local\\claude')).toBe('claude'); }); }); describe('watched-commands store', () => { + it('drops a key no command line can ever produce', () => { + // Written by the pre-fix tokenizer, which ate the backslashes in + // `C:\tools\claude.exe`. A real key is a basename, so it holds no separator. + // A colon outside a leading drive prefix is legal in a POSIX basename. + applyWatchedCommandsFromHost([ + 'C:toolsclaude.exe', + 'claude', + 'foo:bar', + '/usr/bin/claude', + ]); + expect(getWatchedCommands()).toEqual(['claude', 'foo:bar']); + // Same gate on the write path — a drive-relative invocation is the one + // shape `commandArgv0` can still return with a `:` in it. + setCommandWatched('C:foo.exe', true); + expect(getWatchedCommands()).toEqual(['claude', 'foo:bar']); + // A launcher suffix is the other tell: a relative invocation had no + // separator to eat (`tools\\dor.cmd` -> `toolsdor.cmd`), and a bare + // `npm.cmd` stored cleanly — but `commandProgramName` strips the suffix, so + // neither can match again. + applyWatchedCommandsFromHost([ + 'npm.cmd', + 'toolsdor.cmd', + '.build.ps1', + 'claude', + 'foo:bar', + ]); + expect(getWatchedCommands()).toEqual(['claude', 'foo:bar']); + }); + it('adds, reports, and removes rules', () => { expect(getWatchedCommands()).toEqual([]); expect(isCommandWatched('claude')).toBe(false); diff --git a/lib/src/lib/watched-commands.ts b/lib/src/lib/watched-commands.ts index f636e46f..2681f64d 100644 --- a/lib/src/lib/watched-commands.ts +++ b/lib/src/lib/watched-commands.ts @@ -1,5 +1,6 @@ import { loadJson, saveJson } from './local-json-store'; import { getPlatform } from './platform'; +import { WINDOWS_EXECUTABLE_SUFFIX } from './terminal-state'; /** * The WATCHING rule set: the bare program names (`commandArgv0` output) whose @@ -18,15 +19,37 @@ function isStringArray(value: unknown): value is string[] { return Array.isArray(value) && value.every((entry) => typeof entry === 'string'); } +/** + * Whether a stored key is one `commandArgv0` can still produce. It is a bare + * program name, so it holds no separator or legacy Windows drive prefix, and it + * never ends in a launcher suffix — `commandProgramName` strips those. Keys + * written before this module's fixes fail one test or the other: a full path + * mangled to `C:toolsclaude.exe`, a relative one to `toolsdor.cmd`, and a bare + * launcher stored cleanly as `npm.cmd`. Each can only sit in the rule list as + * a row that matches nothing, so it is dropped rather than shown. + * + * Residual: a mangled *relative* path with no suffix (`bin\claude` -> + * `binclaude`) is indistinguishable from a program actually named that, and + * survives. The user deletes it from the rule list. + */ +function isKeyableName(name: string): boolean { + return ( + !/[\\/]/.test(name) && + !/^[A-Za-z]:/.test(name) && + !WINDOWS_EXECUTABLE_SUFFIX.test(name) + ); +} + function readStored(): string[] { - const raw = loadJson(STORAGE_KEY, [], isStringArray); - // Dedupe and drop blanks defensively: the key is user-visible in devtools and - // a malformed entry would otherwise show up as a blank row in the rule list. - return [...new Set(raw.map((name) => name.trim()).filter(Boolean))].sort(); + return normalize(loadJson(STORAGE_KEY, [], isStringArray)); } +// Dedupe and drop what can never be a rule: the key is user-visible in devtools +// and in the rule list, so a malformed entry would otherwise sit there as a row +// that matches nothing. Applied to both sources, `localStorage` and the host's +// canonical snapshot, since a stale key reaches the mirror either way. function normalize(names: string[]): string[] { - return [...new Set(names.map((name) => name.trim()).filter(Boolean))].sort(); + return [...new Set(names.map((name) => name.trim()).filter(Boolean).filter(isKeyableName))].sort(); } let watched: string[] = readStored(); @@ -53,7 +76,10 @@ export function isCommandWatched(name: string | null | undefined): boolean { export function setCommandWatched(name: string, on: boolean): void { const trimmed = name.trim(); - if (!trimmed) return; + // Same gate `normalize` applies on the way in, so a key that would be dropped + // on the next reload is never stored: it would otherwise match for the rest of + // the session and then vanish with nothing on screen to explain it. + if (!trimmed || !isKeyableName(trimmed)) return; if (watched.includes(trimmed) === on) return; watched = on ? [...watched, trimmed].sort() diff --git a/scripts/spec-word-budgets.json b/scripts/spec-word-budgets.json index bad33951..51c705ce 100644 --- a/scripts/spec-word-budgets.json +++ b/scripts/spec-word-budgets.json @@ -27,7 +27,7 @@ "docs/specs/standalone.rationale.md": 375, "docs/specs/terminal-escapes.md": 3950, "docs/specs/terminal-escapes.rationale.md": 350, - "docs/specs/terminal-state.md": 2725, + "docs/specs/terminal-state.md": 2875, "docs/specs/theme.md": 2350, "docs/specs/tiling-engine.md": 5175, "docs/specs/tiling-engine.rationale.md": 900,