diff --git a/apps/web/package.json b/apps/web/package.json index ffccf206f910..53c53c1c7cf9 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -60,6 +60,7 @@ "remark-breaks": "^4.0.0", "remark-gfm": "^4.0.1", "tailwind-merge": "^3.4.0", + "unbash": "^5.0.0", "zustand": "^5.0.11" }, "devDependencies": { diff --git a/apps/web/src/components/chat/HighlightedTokens.test.ts b/apps/web/src/components/chat/HighlightedTokens.test.ts new file mode 100644 index 000000000000..ef4bf456585f --- /dev/null +++ b/apps/web/src/components/chat/HighlightedTokens.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { embeddedScripts } from "../../lib/embeddedScripts"; +import { keyedLines, withEmbeddedScripts, wordsOf } from "./HighlightedTokens"; + +function tokensOf(...contents: string[]) { + let offset = 0; + return contents.map((content) => { + const token = { content, offset }; + offset += content.length; + return token; + }); +} + +function shape(parts: ReturnType) { + return parts.map((part) => + typeof part === "string" ? part : part.pieces.map((piece) => piece.text).join(""), + ); +} + +describe("wordsOf", () => { + it.each([ + // Shiki splits these shell words into several tokens. + [["echo", " ", '"', "$HOME", "/${", "PATH", "%%:*", '}"'], 'echo "$HOME/${PATH%%:*}"'], + [["rsync", " ", "-a", " ", "--exclude", " ", "build"], "rsync -a --exclude build"], + // A token can carry whitespace on either side or inside a string. + [["git", " && ", "echo", " ", '"two words"', "; "], 'git && echo "two words"; '], + [[" ", "indented"], " indented"], + [[], ""], + ])("wraps the same words as the plain-text fallback: %j", (contents, line) => { + expect(shape(wordsOf(tokensOf(...contents)))).toEqual( + line.split(/(\s+)/u).filter((part) => part !== ""), + ); + }); + + it("keeps each piece's own token so colors survive regrouping", () => { + const [word] = wordsOf(tokensOf('"', "$HOME", '"')); + expect( + typeof word === "string" ? null : word?.pieces.map((piece) => piece.token.content), + ).toEqual(['"', "$HOME", '"']); + }); +}); + +describe("keyedLines", () => { + it.each([ + "git status\ngit diff", + "Get-ChildItem\r\nWrite-Output done\r\n", + "mixed\r\nendings\n\nlone \r stays", + ])("renders the same text as the source: %j", (code) => { + // Shiki splits lines on \r?\n and drops the ending from the tokens. + const lines = code.split(/\r?\n/u).map((line) => tokensOf(line)); + const rendered = keyedLines(code, lines) + .map(({ tokens, ending }) => tokens.map((token) => token.content).join("") + ending) + .join(""); + expect(rendered).toBe(code); + }); + + it("keys empty lines apart", () => { + const code = "a\r\n\r\n\r\nb"; + const keys = keyedLines( + code, + code.split(/\r?\n/u).map((line) => tokensOf(line)), + ).map((line) => line.key); + expect(new Set(keys).size).toBe(keys.length); + }); +}); + +describe("withEmbeddedScripts", () => { + // A stand-in grammar: each word is one token colored with the language name, + // and lines split the way Shiki splits them. + function tokenize(text: string, language: string) { + if (language === "unknown") throw new Error("Language not found"); + let lineStart = 0; + return text.split(/\r?\n/u).map((line) => { + const tokens = [...line.matchAll(/\S+|\s+/gu)].map((match) => ({ + content: match[0], + offset: lineStart + match.index, + color: language, + })); + lineStart += + line.length + (text.slice(lineStart + line.length).match(/^\r?\n/u)?.[0].length ?? 0); + return tokens; + }); + } + + function colorsOf(code: string, scripts = embeddedScripts(code)) { + const lines = withEmbeddedScripts(code, tokenize(code, "shellscript"), scripts, tokenize); + expect(lines.map((line) => line.map((token) => token.content).join("")).join("\n")).toBe( + code.replaceAll("\r\n", "\n"), + ); + return lines.flat().map((token) => [token.content, token.color]); + } + + it("colors each nested script with its own grammar, keeping the text", () => { + expect(colorsOf(`bash -lc "python3 -c 'print(1)'"`)).toEqual([ + [`bash -lc "python3 -c '`, "shellscript"], + ["print(1)", "python"], + [`'"`, "shellscript"], + ]); + }); + + it("colors an escape sequence like the character it spells", () => { + expect(colorsOf(`bash -c "python3 -c 'print(\\"x\\")'"`)).toContainEqual([ + `print(\\"x\\")`, + "python", + ]); + }); + + it("keeps the surrounding colors where a grammar is missing", () => { + const code = "cat > x.ts <<'EOF'\r\nbody\r\nEOF"; + const [script] = embeddedScripts(code); + // Compare per character: untouched lines keep their grammar's token boundaries. + const characterColors = (scripts: ReturnType) => + colorsOf(code, scripts).flatMap(([content, color]) => [...content!].map(() => color)); + expect(characterColors([{ ...script!, language: "unknown" }])).toEqual(characterColors([])); + }); +}); diff --git a/apps/web/src/components/chat/HighlightedTokens.tsx b/apps/web/src/components/chat/HighlightedTokens.tsx new file mode 100644 index 000000000000..1d7f1522383f --- /dev/null +++ b/apps/web/src/components/chat/HighlightedTokens.tsx @@ -0,0 +1,204 @@ +import { use, useMemo, type CSSProperties } from "react"; + +import { resolveDiffThemeName } from "../../lib/diffRendering"; +import type { EmbeddedScript } from "../../lib/embeddedScripts"; +import { getSyntaxHighlighterPromise } from "../../lib/syntaxHighlighting"; + +interface SyntaxToken { + readonly content: string; + readonly offset: number; + readonly color?: string; + readonly fontStyle?: number; +} + +type TokenLines = ReadonlyArray>; + +const NO_EMBEDDED_SCRIPTS: ReadonlyArray = []; + +function syntaxTokenStyle(token: SyntaxToken): CSSProperties { + const fontStyle = token.fontStyle ?? 0; + return { + ...(token.color ? { color: token.color } : {}), + ...(fontStyle & 1 ? { fontStyle: "italic" } : {}), + ...(fontStyle & 2 ? { fontWeight: 700 } : {}), + ...(fontStyle & 4 ? { textDecoration: "underline" } : {}), + }; +} + +/** + * Colors `code` inside the caller's `
` without changing its text, so
+ * selection and copy match the plain version. Suspends while the grammar
+ * loads; wrap it in Suspense with the plain text as the fallback.
+ * `wordClassName` goes on each whitespace-separated word. `embedded`
+ * scripts are colored with their own grammar inside `code`.
+ */
+export function HighlightedTokens({
+  code,
+  language,
+  embedded = NO_EMBEDDED_SCRIPTS,
+  theme,
+  wordClassName,
+}: {
+  code: string;
+  language: string;
+  embedded?: ReadonlyArray;
+  theme: "light" | "dark";
+  wordClassName?: string;
+}) {
+  const highlighter = use(getSyntaxHighlighterPromise(language));
+  // Every grammar loads into the same shared highlighter.
+  for (const script of embedded) use(getSyntaxHighlighterPromise(script.language));
+  const lines = useMemo(() => {
+    const themeName = resolveDiffThemeName(theme);
+    const tokenize = (text: string, lang: string) =>
+      highlighter.codeToTokens(text, { lang, theme: themeName }).tokens;
+    return keyedLines(
+      code,
+      withEmbeddedScripts(code, tokenize(code, language), embedded, tokenize),
+    );
+  }, [code, embedded, highlighter, language, theme]);
+
+  return lines.map(({ key, tokens, ending }) => (
+    
+      {wordClassName
+        ? wordsOf(tokens).map((part) =>
+            typeof part === "string" ? (
+              part
+            ) : (
+              
+                {part.pieces.map((piece) => (
+                  
+                    {piece.text}
+                  
+                ))}
+              
+            ),
+          )
+        : tokens.map((token) => (
+            
+              {token.content}
+            
+          ))}
+      {ending}
+    
+  ));
+}
+
+/**
+ * Recolors each embedded script's span of `code` with its own grammar,
+ * outermost first so nested scripts win. A script whose grammar is missing
+ * keeps the colors around it.
+ */
+export function withEmbeddedScripts(
+  code: string,
+  lines: TokenLines,
+  embedded: ReadonlyArray,
+  tokenize: (text: string, language: string) => TokenLines,
+): TokenLines {
+  if (embedded.length === 0) return lines;
+  const styles = Array.from({ length: code.length });
+  for (const token of lines.flat()) {
+    styles.fill(token, token.offset, token.offset + token.content.length);
+  }
+  for (const script of embedded) {
+    let scriptLines: TokenLines;
+    try {
+      scriptLines = tokenize(script.text, script.language);
+    } catch {
+      continue;
+    }
+    for (const token of scriptLines.flat()) {
+      if (token.content === "") continue;
+      const last = token.offset + token.content.length - 1;
+      styles.fill(token, script.starts[token.offset], script.ends[last]);
+    }
+  }
+  return restyledLines(code, styles);
+}
+
+/** Splits `code` into lines of tokens, one per run of characters sharing a style. */
+function restyledLines(
+  code: string,
+  styles: ReadonlyArray,
+): SyntaxToken[][] {
+  const lines: SyntaxToken[][] = [];
+  let start = 0;
+  for (const lineBreak of [...code.matchAll(/\r?\n/gu), undefined]) {
+    const end = lineBreak?.index ?? code.length;
+    const tokens: SyntaxToken[] = [];
+    for (let index = start; index < end;) {
+      const style = styles[index];
+      let next = index + 1;
+      while (
+        next < end &&
+        styles[next]?.color === style?.color &&
+        styles[next]?.fontStyle === style?.fontStyle
+      ) {
+        next += 1;
+      }
+      tokens.push({
+        content: code.slice(index, next),
+        offset: index,
+        ...(style?.color ? { color: style.color } : {}),
+        ...(style?.fontStyle ? { fontStyle: style.fontStyle } : {}),
+      });
+      index = next;
+    }
+    lines.push(tokens);
+    if (lineBreak) start = lineBreak.index + lineBreak[0].length;
+  }
+  return lines;
+}
+
+/**
+ * Keys each line by its start offset, which stays unique when several lines
+ * are empty, and pairs it with the line ending `code` has after it. Shiki drops
+ * `\n` and `\r\n` from tokens alike, so CRLF has to come back from the source.
+ */
+export function keyedLines(
+  code: string,
+  lines: ReadonlyArray>,
+) {
+  const endings = code.match(/\r?\n/gu) ?? [];
+  let lineStart = 0;
+  return lines.map((tokens, index) => {
+    const text = tokens.map((token) => token.content).join("");
+    const key = `${lineStart}:${text}`;
+    const ending = endings[index] ?? "";
+    lineStart += text.length + ending.length;
+    return { key, tokens, ending };
+  });
+}
+
+interface Word {
+  readonly key: string;
+  readonly pieces: Array<{ key: string; text: string; token: SyntaxToken }>;
+}
+
+/**
+ * Regroups a line's tokens into whitespace runs and whole words, matching
+ * `text.split(/(\s+)/)`, so a word made of several tokens (`"$HOME/x"`) wraps
+ * exactly like its plain-text fallback.
+ */
+export function wordsOf(tokens: ReadonlyArray): Array {
+  const parts: Array = [];
+  let word: Word | null = null;
+  for (const token of tokens) {
+    let offset = token.offset;
+    for (const text of token.content.split(/(\s+)/u)) {
+      if (text === "") continue;
+      if (/^\s/u.test(text)) {
+        parts.push(text);
+        word = null;
+      } else {
+        if (!word) {
+          word = { key: String(offset), pieces: [] };
+          parts.push(word);
+        }
+        word.pieces.push({ key: String(offset), text, token });
+      }
+      offset += text.length;
+    }
+  }
+  return parts;
+}
diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts
index 280cc3672103..81b4af628630 100644
--- a/apps/web/src/components/chat/MessagesTimeline.logic.ts
+++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts
@@ -121,7 +121,7 @@ export function workEntryDisplayLabel(entry: WorkLogEntry, workspaceRoot: string
   }
   const toolPresentation = resolveWorkEntryToolPresentation(entry);
   if (toolPresentation) return toolPresentation.displayName;
-  if (entry.command) return commandDisplayText(entry.command);
+  if (entry.command?.trim()) return commandDisplayText(entry.command);
   const action = toolGroupAction(entry);
   if (action === "code-search" || action === "search") {
     // Adapters title file searches with their target; the item keeps only the pattern.
diff --git a/apps/web/src/components/chat/ShellCommandBlock.tsx b/apps/web/src/components/chat/ShellCommandBlock.tsx
new file mode 100644
index 000000000000..cd264eaaa903
--- /dev/null
+++ b/apps/web/src/components/chat/ShellCommandBlock.tsx
@@ -0,0 +1,76 @@
+import {
+  commandHighlightLanguage,
+  withVisibleControlCharacters,
+} from "@t3tools/client-runtime/work-log/command-label";
+import { Suspense, use, useMemo } from "react";
+
+import { useTheme } from "../../hooks/useTheme";
+import { RenderErrorBoundary } from "../RenderErrorBoundary";
+import { HighlightedTokens } from "./HighlightedTokens";
+
+// Shell words wrap as a unit, so `--exclude` or a quoted string is not split
+// at a hyphen; a word longer than the line still breaks anywhere.
+const WORD_CLASS_NAME = "inline-block max-w-full [overflow-wrap:anywhere]";
+
+// The shell parser loads with the first expanded command, not with the timeline.
+let embeddedScriptsModule: Promise | undefined;
+function loadEmbeddedScripts() {
+  embeddedScriptsModule ??= import("../../lib/embeddedScripts");
+  return embeddedScriptsModule;
+}
+
+/** Same layout as the highlighted version, so the grammar arriving never reflows the block. */
+function PlainWords({ code }: { code: string }) {
+  // split with a capture group alternates words (even) and whitespace (odd).
+  return code.split(/(\s+)/u).map((part, index) =>
+    index % 2 === 1 || part === "" ? (
+      part
+    ) : (
+      // Positions are stable for a given string, which is all this renders.
+      // oxlint-disable-next-line react/no-array-index-key
+      
+        {part}
+      
+    ),
+  );
+}
+
+function HighlightedCommand({ code, theme }: { code: string; theme: "light" | "dark" }) {
+  const { embeddedScripts } = use(loadEmbeddedScripts());
+  const language = commandHighlightLanguage(code);
+  // Only shell syntax nests scripts; PowerShell's own grammar colors its strings.
+  const embedded = useMemo(
+    () => (language === "shellscript" ? embeddedScripts(code) : []),
+    [code, embeddedScripts, language],
+  );
+  return (
+    
+  );
+}
+
+/**
+ * The command a command_execution item ran, syntax highlighted. Scripts inside
+ * it, such as a `bash -lc` script or a Python heredoc, get their own grammar.
+ */
+export function ShellCommandBlock({ command }: { command: string }) {
+  const { resolvedTheme } = useTheme();
+  const code = withVisibleControlCharacters(command.trim());
+  if (!code) return null;
+  const plain = ;
+  return (
+    // The tool body sets the monospace, pre-wrapped text this sits in.
+    
+ + + + + +
+ ); +} diff --git a/apps/web/src/components/chat/SnapShotAttachmentDetails.tsx b/apps/web/src/components/chat/SnapShotAttachmentDetails.tsx index 793da952c6ef..ca6e366fe829 100644 --- a/apps/web/src/components/chat/SnapShotAttachmentDetails.tsx +++ b/apps/web/src/components/chat/SnapShotAttachmentDetails.tsx @@ -1,15 +1,14 @@ import type { SnapShotSource } from "@t3tools/contracts"; import { ImageIcon, TextIcon } from "lucide-react"; -import { Suspense, use, useMemo, type CSSProperties } from "react"; +import { Suspense } from "react"; import { useTheme } from "../../hooks/useTheme"; -import { resolveDiffThemeName } from "../../lib/diffRendering"; -import { getSyntaxHighlighterPromise } from "../../lib/syntaxHighlighting"; import { cn } from "../../lib/utils"; import { RenderErrorBoundary } from "../RenderErrorBoundary"; import { Button } from "../ui/button"; import { Popover, PopoverPopup, PopoverTitle, PopoverTrigger } from "../ui/popover"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { HighlightedTokens } from "./HighlightedTokens"; export const SNAP_SHOT_ATTACHMENT_FRAME_CLASS = "relative h-28 w-52 max-w-full overflow-hidden rounded-lg border border-border/80"; @@ -19,59 +18,6 @@ export interface SnapShotAccessibilityDetails { format: "json" | "text"; } -interface SyntaxToken { - readonly content: string; - readonly offset: number; - readonly color?: string; - readonly fontStyle?: number; -} - -function syntaxTokenStyle(token: SyntaxToken): CSSProperties { - const fontStyle = token.fontStyle ?? 0; - return { - ...(token.color ? { color: token.color } : {}), - ...(fontStyle & 1 ? { fontStyle: "italic" } : {}), - ...(fontStyle & 2 ? { fontWeight: 700 } : {}), - ...(fontStyle & 4 ? { textDecoration: "underline" } : {}), - }; -} - -function HighlightedAccessibilityJson({ - content, - theme, -}: { - content: string; - theme: "light" | "dark"; -}) { - const highlighter = use(getSyntaxHighlighterPromise("json")); - const lines = useMemo( - () => - highlighter.codeToTokens(content, { - lang: "json", - theme: resolveDiffThemeName(theme), - }).tokens, - [content, highlighter, theme], - ); - - let lineOffset = 0; - return lines.map((line) => { - const lineContent = line.map((token) => token.content).join(""); - const lineKey = `${lineOffset}:${lineContent}`; - const hasNextLine = lineOffset + lineContent.length < content.length; - lineOffset += lineContent.length + 1; - return ( - - {line.map((token) => ( - - {token.content} - - ))} - {hasNextLine ? "\n" : null} - - ); - }); -} - export function SnapShotAccessibilityData({ details, className, @@ -84,7 +30,7 @@ export function SnapShotAccessibilityData({ details.format === "json" ? ( - + ) : ( diff --git a/apps/web/src/components/chat/V2ItemInspector.tsx b/apps/web/src/components/chat/V2ItemInspector.tsx index 0b8275065549..7dc48a9e00a4 100644 --- a/apps/web/src/components/chat/V2ItemInspector.tsx +++ b/apps/web/src/components/chat/V2ItemInspector.tsx @@ -24,6 +24,7 @@ import { Button } from "../ui/button"; import ChatMarkdown from "../ChatMarkdown"; import { RenderErrorBoundary } from "../RenderErrorBoundary"; import { resolveExternalWebLinkHref } from "./externalLinkContextMenu"; +import { ShellCommandBlock } from "./ShellCommandBlock"; interface V2ItemInspectorProps { readonly projectedItem: OrchestrationV2ProjectedTurnItem; @@ -173,7 +174,7 @@ function ToolCallBody( const call = toolCallLines({ command: props.command, args: props.args }); return (
- {call.command ?
{call.command}
: null} + {call.command ? : null} {call.args ? (
{call.args.map(([key, value]) => ( diff --git a/apps/web/src/lib/embeddedScripts.test.ts b/apps/web/src/lib/embeddedScripts.test.ts new file mode 100644 index 000000000000..d99263b6479a --- /dev/null +++ b/apps/web/src/lib/embeddedScripts.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { embeddedScripts } from "./embeddedScripts"; + +/** Each script's language, its text, and the part of the command that spells it. */ +function scriptsOf(command: string) { + return embeddedScripts(command).map((script) => { + // Every character maps inside the command, in order. + script.starts.forEach((start, index) => { + expect(start).toBeLessThan(script.ends[index]!); + if (index > 0) expect(start).toBeGreaterThanOrEqual(script.ends[index - 1]!); + }); + return [ + script.language, + script.text, + command.slice(script.starts[0], script.ends.at(-1)), + ] as const; + }); +} + +const pythonScript = `from pathlib import Path +import json + +# print every package.json name and version +for p in sorted(Path('.').glob('**/package.json')): + data = json.loads(p.read_text()) + print(f"{data['name']:16} {data.get('version', '?')}") +`; + +describe("embeddedScripts", () => { + it("finds the script inside a Codex login-shell wrapper and the script inside that", () => { + // Recorded in the turn_interrupt_mid_tool Codex transcript + // (apps/server/src/orchestration-v2/testkit/fixtures). + const inner = `console.log('interrupt fixture tool started'); setTimeout(() => {}, 30000)`; + const command = `/bin/bash -lc "node -e \\"${inner}\\""`; + expect(scriptsOf(command)).toEqual([ + ["shellscript", `node -e "${inner}"`, `node -e \\"${inner}\\"`], + ["javascript", inner, inner], + ]); + }); + + it("highlights a heredoc fed to an interpreter in that interpreter's language", () => { + const command = `python3 - <<'PY'\n${pythonScript}PY`; + expect(scriptsOf(command)).toEqual([["python", pythonScript, pythonScript]]); + }); + + it("maps escaped characters back to both source characters", () => { + const command = `/bin/zsh -lc "python3 - <<'PY'\nprint(\\"\\$HOME\\")\nPY"`; + const [shell, python] = scriptsOf(command); + expect(shell?.[1]).toBe(`python3 - <<'PY'\nprint("$HOME")\nPY`); + expect(python).toEqual(["python", `print("$HOME")\n`, `print(\\"\\$HOME\\")\n`]); + }); + + it.each([ + [`python3 -c 'print(1)'`, "python", "print(1)"], + [`python3 -X dev -c 'print(1)'`, "python", "print(1)"], + [`py -3 -c 'print(1)'`, "python", "print(1)"], + [`C:\\Python312\\python.exe -c 'print(1)'`, "python", "print(1)"], + [`"C:\\Program Files\\Python312\\python.exe" -c 'print(1)'`, "python", "print(1)"], + [`node --eval "process.exit(0)"`, "javascript", "process.exit(0)"], + [`bun -e 'console.log(Bun.version)'`, "typescript", "console.log(Bun.version)"], + [`deno eval 'console.log(1)'`, "typescript", "console.log(1)"], + [`deno eval --config deno.json 'console.log(1)'`, "typescript", "console.log(1)"], + [`deno eval --env-file 'console.log(1)'`, "typescript", "console.log(1)"], + [`psql -f schema.sql -c 'select 1'`, "sql", "select 1"], + [`node -C development -e 'console.log(1)'`, "javascript", "console.log(1)"], + [`ruby -e 'puts 1'`, "ruby", "puts 1"], + [`ruby -I lib -e 'puts 1'`, "ruby", "puts 1"], + [`perl -E 'say 1'`, "perl", "say 1"], + [`sqlite3 app.db "select * from users"`, "sql", "select * from users"], + [`psql -d app -c 'select 1'`, "sql", "select 1"], + [`pwsh -NoProfile -Command "Get-ChildItem"`, "powershell", "Get-ChildItem"], + [`env FOO=1 sudo -u bob python3 -c 'print(1)'`, "python", "print(1)"], + [`env -- FOO=1 python3 -c 'print(1)'`, "python", "print(1)"], + [`node --eval="process.exit(0)"`, "javascript", "process.exit(0)"], + [`psql --command='select 1'`, "sql", "select 1"], + [`mysql -c -e 'select 1'`, "sql", "select 1"], + [`mysql -f app <<'EOF'\nselect 1;\nEOF`, "sql", "select 1;\n"], + [`psql app <<'EOF'\nselect 1;\nEOF`, "sql", "select 1;\n"], + [`echo "$(node -e 'console.log(1)')"`, "javascript", "console.log(1)"], + [`python3 <<< 'print(1)'`, "python", "print(1)"], + ])("finds the inline script in %s", (command, language, script) => { + expect(scriptsOf(command)).toEqual([[language, script, expect.any(String)]]); + }); + + it.each([[`psql -c 'select 1' -c 'select 2'`], [`sqlite3 app.db "select 1" "select 2"`]])( + "finds every statement a SQL shell runs: %s", + (command) => { + expect(scriptsOf(command).map(([language, text]) => [language, text])).toEqual([ + ["sql", "select 1"], + ["sql", "select 2"], + ]); + }, + ); + + it.each([ + ["the file `cat` writes", `cat > src/app.ts <<'EOF'\nconst a = 1;\nEOF`, "typescript"], + ["the file `tee` writes", `tee -a config.json < { + expect(scriptsOf(command).map(([lang]) => lang)).toEqual([language]); + }); + + it.each([ + ["a script file", `python3 build.py <log.ts <<'EOF'\nx\nEOF`], + ])("finds nothing for %s", (_case, command) => { + expect(scriptsOf(command)).toEqual([]); + }); + + it("trusts nothing in a command whose quoting does not parse", () => { + // Codex's secret redaction dropped the backslash of an escaped quote, so + // the -lc argument ends early and the rest no longer balances. + const command = `/bin/zsh -lc "python3 -c 'token=[REDACTED_SECRET]"apiToken\\"]; print(token)'"`; + expect(scriptsOf(command)).toEqual([]); + }); +}); diff --git a/apps/web/src/lib/embeddedScripts.ts b/apps/web/src/lib/embeddedScripts.ts new file mode 100644 index 000000000000..809481b6973f --- /dev/null +++ b/apps/web/src/lib/embeddedScripts.ts @@ -0,0 +1,544 @@ +import { getFiletypeFromFileName } from "@pierre/diffs"; +import { + parse, + type Command, + type DoubleQuotedChild, + type HereDoc, + type Word, + type WordPart, +} from "unbash"; + +/** + * Code in another language inside a shell command: a `bash -lc "…"` script, a + * `python3 -c '…'` argument, or a heredoc fed to an interpreter or written to + * a file. `starts[i]` and `ends[i]` are the span of the original command that + * spells `text[i]`, so a quoted `\"` maps back to both of its characters. + */ +export interface EmbeddedScript { + readonly language: string; + readonly text: string; + readonly starts: ReadonlyArray; + readonly ends: ReadonlyArray; +} + +interface MappedText { + readonly text: string; + readonly starts: ReadonlyArray; + readonly ends: ReadonlyArray; +} + +interface Interpreter { + readonly language: string; + /** Flags whose next argument is the script. */ + readonly inline: (flag: string) => boolean; + /** Options that consume the next argument. */ + readonly valueOptions?: ReadonlySet; + /** Options after which the script comes from a file or module instead. */ + readonly scriptFileOptions?: ReadonlySet; + /** "always" for SQL shells, which read statements from stdin next to their database argument. */ + readonly stdin: "always" | "without-script-file" | "never"; + /** The first positional argument holding a script, as in `sqlite3 app.db "select 1"`. */ + readonly positionalScript?: number; + /** SQL shells run every `-c` and every statement argument, in order. */ + readonly repeatable?: boolean; + readonly subcommand?: string; + /** PowerShell matches parameters case-insensitively; the lists above are lowercase. */ + readonly ignoreCase?: boolean; +} + +const MAX_DEPTH = 4; + +const flagIn = + (...flags: string[]) => + (flag: string) => + flags.includes(flag); + +const SHELL: Interpreter = { + language: "shellscript", + inline: (flag) => /^-[a-z]*c[a-z]*$/u.test(flag), + valueOptions: new Set(["-o", "-O", "--rcfile", "--init-file"]), + stdin: "without-script-file", +}; + +const PYTHON: Interpreter = { + language: "python", + inline: flagIn("-c"), + valueOptions: new Set(["-W", "-X"]), + scriptFileOptions: new Set(["-m"]), + stdin: "without-script-file", +}; + +// Shared by SQL clients; each names its own script flags, since `-c` and `-f` +// mean different things to psql and mysql. +const SQL_SHELL = { + language: "sql", + valueOptions: new Set([ + "-d", + "-h", + "-u", + "-U", + "--dbname", + "--host", + "--user", + "--username", + "-separator", + "-nullvalue", + "-init", + ]), + stdin: "always", + repeatable: true, +} satisfies Omit; + +const INTERPRETERS: Record = { + sh: SHELL, + bash: SHELL, + zsh: SHELL, + dash: SHELL, + ash: SHELL, + ksh: SHELL, + fish: { language: "fish", inline: flagIn("-c", "--command"), stdin: "without-script-file" }, + node: { + language: "javascript", + inline: flagIn("-e", "--eval", "-p", "--print"), + valueOptions: new Set(["-r", "--require", "--import", "--loader", "-C", "--conditions"]), + stdin: "without-script-file", + }, + bun: { language: "typescript", inline: flagIn("-e", "--eval", "-p", "--print"), stdin: "never" }, + deno: { + language: "typescript", + inline: () => false, + // `--env-file` takes its optional path only as `--env-file=path`. + valueOptions: new Set(["-c", "--config", "--import-map", "--ext"]), + stdin: "never", + subcommand: "eval", + }, + ruby: { + language: "ruby", + inline: flagIn("-e"), + valueOptions: new Set(["-r", "-I", "-C"]), + stdin: "without-script-file", + }, + perl: { language: "perl", inline: flagIn("-e", "-E"), stdin: "without-script-file" }, + osascript: { + language: "applescript", + inline: flagIn("-e"), + valueOptions: new Set(["-l", "-s"]), + stdin: "without-script-file", + }, + pwsh: { + language: "powershell", + inline: flagIn("-c", "-command"), + valueOptions: new Set(["-executionpolicy", "-workingdirectory", "-windowstyle"]), + scriptFileOptions: new Set(["-f", "-file", "-encodedcommand"]), + stdin: "never", + ignoreCase: true, + }, + psql: { + ...SQL_SHELL, + inline: flagIn("-c", "--command"), + scriptFileOptions: new Set(["-f", "--file"]), + }, + mysql: { ...SQL_SHELL, inline: flagIn("-e", "--execute") }, + sqlite3: { ...SQL_SHELL, inline: flagIn("-cmd"), positionalScript: 1 }, + duckdb: { + ...SQL_SHELL, + inline: flagIn("-c", "-cmd"), + scriptFileOptions: new Set(["-f"]), + positionalScript: 1, + }, +}; +INTERPRETERS.powershell = INTERPRETERS.pwsh!; +INTERPRETERS.mariadb = INTERPRETERS.mysql!; + +// Commands that run the next word as the program, with the options that consume a value. +const PROGRAM_WRAPPERS: Record> = { + env: new Set(["-u", "--unset", "-C", "--chdir"]), + sudo: new Set(["-u", "--user", "-g", "--group", "-C", "-D", "--chdir"]), + nice: new Set(["-n", "--adjustment"]), + nohup: new Set(), + exec: new Set(["-a"]), + command: new Set(), + builtin: new Set(), +}; + +// Heredoc delimiters that name their language, as in `<<'SQL'`. +const DELIMITER_LANGUAGES: Record = { + py: "python", + python: "python", + js: "javascript", + javascript: "javascript", + ts: "typescript", + typescript: "typescript", + sql: "sql", + json: "json", + yaml: "yaml", + yml: "yaml", + toml: "toml", + sh: "shellscript", + bash: "shellscript", + shell: "shellscript", + rb: "ruby", + ruby: "ruby", + html: "html", + css: "css", + md: "markdown", + markdown: "markdown", + xml: "xml", + diff: "diff", + patch: "diff", +}; + +/** + * The scripts embedded in a shell command, outermost first, so a highlighter + * painting them in order lets a nested script override the one containing it. + * Returns nothing for a command that does not parse cleanly; where a broken + * quote ends cannot be trusted. + */ +export function embeddedScripts(command: string): EmbeddedScript[] { + const identity = Array.from({ length: command.length }, (_, index) => index); + return scriptsIn( + { text: command, starts: identity, ends: identity.map((index) => index + 1) }, + 0, + ); +} + +function scriptsIn(source: MappedText, depth: number): EmbeddedScript[] { + const script = parse(source.text); + if (script.errors?.length) return []; + const found: EmbeddedScript[] = []; + forEachCommand(script.commands, (command) => { + for (const { language, mapped } of commandScripts(command, source.text)) { + if (mapped.text.trim() === "") continue; + const embedded: EmbeddedScript = { + language, + text: mapped.text, + starts: mapped.starts.map((start) => source.starts[start]!), + ends: mapped.ends.map((end) => source.ends[end - 1]!), + }; + found.push(embedded); + if (language === "shellscript" && depth + 1 < MAX_DEPTH) { + found.push(...scriptsIn(embedded, depth + 1)); + } + } + }); + return found; +} + +function isNode(value: unknown): value is { readonly type: string } { + return typeof value === "object" && value !== null && "type" in value; +} + +/** Visits every simple command, including those inside `$(…)`, loops and subshells. */ +function forEachCommand( + node: unknown, + visit: (command: Command) => void, + seen = new WeakSet(), +): void { + if (Array.isArray(node)) { + for (const child of node) forEachCommand(child, visit, seen); + return; + } + if (!isNode(node) || seen.has(node)) return; + seen.add(node); + switch (node.type) { + case "Command": + visit(node as Command); + break; + case "CommandExpansion": + case "ProcessSubstitution": { + const { script } = node as Extract; + // Escaped-backtick scripts own a decoded source their positions index. + if (script && !script.errors?.length && script.source === undefined) { + forEachCommand(script.commands, visit, seen); + } + return; + } + } + // Word parts are lazy getters, which Object.values skips. + if ("parts" in node) forEachCommand(node.parts, visit, seen); + for (const value of Object.values(node)) { + if (typeof value === "object") forEachCommand(value, visit, seen); + } +} + +function programName(word: Word): string { + // Bash reads `C:\Python312\python.exe` unquoted as escapes; Windows meant a path. + const path = /\\/u.test(word.text) ? word.text.replace(/^["']|["']$/gu, "") : word.value; + return (path.split(/[\\/]/u).at(-1) ?? "").toLowerCase().replace(/\.exe$/u, ""); +} + +function interpreterFor(name: string): Interpreter | undefined { + // `py` is the Windows Python launcher, as in `py -3 -c '…'`. + if (/^python(?:\d+(?:\.\d+)?)?$|^pypy3?$|^py$/u.test(name)) return PYTHON; + return Object.hasOwn(INTERPRETERS, name) ? INTERPRETERS[name] : undefined; +} + +/** Skips `env FOO=1`, `sudo -u bob` and similar to the word naming the real program. */ +function programIndex(words: ReadonlyArray): number { + let index = 0; + while (index < words.length) { + const name = programName(words[index]!); + if (!Object.hasOwn(PROGRAM_WRAPPERS, name)) return index; + const valueOptions = PROGRAM_WRAPPERS[name]!; + index += 1; + let optionsEnded = false; + while (index < words.length) { + const value = words[index]!.value; + const isOption = !optionsEnded && value.startsWith("-"); + if (isOption && value === "--") optionsEnded = true; + // `env -- FOO=1 cmd` still takes assignments after the options end. + if (isOption || /^[A-Za-z_]\w*=/u.test(value)) { + index += isOption && valueOptions.has(value) ? 2 : 1; + } else { + break; + } + } + } + return index; +} + +function commandScripts( + command: Command, + source: string, +): Array<{ language: string; mapped: MappedText }> { + if (!command.name) return []; + const words = [command.name, ...command.args.filter((arg): arg is Word => arg.type === "Word")]; + const index = programIndex(words); + const program = words[index]; + if (!program) return []; + const name = programName(program); + const interpreter = interpreterFor(name); + const args = words.slice(index + 1); + const scripts: Array<{ language: string; mapped: MappedText }> = []; + + let readsStdinScript = false; + if (interpreter) { + const found = interpreterArgs(interpreter, args); + for (const script of found.scripts) { + const mapped = wordText(script.word, source); + if (!mapped) continue; + const start = script.inValue ? mapped.text.indexOf("=") + 1 : 0; + scripts.push({ language: interpreter.language, mapped: sliceMapped(mapped, start) }); + } + readsStdinScript = + interpreter.stdin !== "never" && found.scripts.length === 0 && !found.scriptFromFile; + } + + const stdinLanguage = readsStdinScript ? interpreter?.language : undefined; + const writtenLanguage = writtenFileLanguage(name, command, args); + for (const redirect of command.redirects) { + const toStdin = isDescriptor(redirect.descriptor, 0); + if (redirect.type === "HereString" && toStdin && stdinLanguage && redirect.target) { + const mapped = wordText(redirect.target, source); + if (mapped) scripts.push({ language: stdinLanguage, mapped }); + } + if (redirect.type === "HereDoc") { + const language = + (toStdin ? (stdinLanguage ?? writtenLanguage) : undefined) ?? delimiterLanguage(redirect); + if (language) scripts.push({ language, mapped: hereDocText(redirect, source) }); + } + } + return scripts; +} + +/** Whether a redirection without a descriptor, or with `fd`, targets file descriptor `fd`. */ +function isDescriptor(descriptor: HereDoc["descriptor"], fd: number): boolean { + return ( + descriptor === undefined || (descriptor.type === "FileDescriptor" && descriptor.value === fd) + ); +} + +interface ScriptArgument { + readonly word: Word; + /** The script follows the `=` of `--eval=…` rather than filling the word. */ + readonly inValue: boolean; +} + +/** The script arguments, and whether the script comes from a file instead. */ +function interpreterArgs( + interpreter: Interpreter, + args: ReadonlyArray, +): { scripts: ScriptArgument[]; scriptFromFile: boolean } { + const positionals: Word[] = []; + const scripts: ScriptArgument[] = []; + const whole = (word: Word): ScriptArgument => ({ word, inValue: false }); + let optionsEnded = false; + let scriptFromFile = false; + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]!; + const flag = interpreter.ignoreCase ? arg.value.toLowerCase() : arg.value; + if (!optionsEnded && flag === "--") { + optionsEnded = true; + continue; + } + if (!optionsEnded && flag.startsWith("-") && flag !== "-") { + // `--eval=code` and `--file=path` carry their value in the same word. + const [name = flag, value] = flag.startsWith("--") ? flag.split(/=(.*)/su) : [flag]; + if (interpreter.scriptFileOptions?.has(name)) { + if (!interpreter.repeatable) return { scripts, scriptFromFile: true }; + // SQL shells run `-f` files and `-c` statements alike, in order. + scriptFromFile = true; + if (value === undefined) index += 1; + continue; + } + if (interpreter.inline(name)) { + const next = args[index + 1]; + if (value !== undefined) scripts.push({ word: arg, inValue: true }); + else if (next) scripts.push(whole(next)); + if (!interpreter.repeatable) return { scripts, scriptFromFile: false }; + if (value === undefined) index += 1; + continue; + } + if (value === undefined && interpreter.valueOptions?.has(flag)) index += 1; + continue; + } + positionals.push(arg); + // An interpreter stops reading its own options at the script file. + if (interpreter.stdin === "without-script-file" && !interpreter.subcommand) break; + } + + if (interpreter.subcommand) { + if (positionals[0]?.value !== interpreter.subcommand) return { scripts, scriptFromFile: true }; + return { scripts: positionals.slice(1, 2).map(whole), scriptFromFile: false }; + } + if (interpreter.positionalScript !== undefined) { + scripts.push(...positionals.slice(interpreter.positionalScript).map(whole)); + } + // SQL shells take databases as positional arguments, not script files. + if (interpreter.stdin === "always") return { scripts, scriptFromFile }; + const first = positionals[0]; + return { scripts, scriptFromFile: first !== undefined && first.value !== "-" }; +} + +function sliceMapped(mapped: MappedText, start: number): MappedText { + if (start === 0) return mapped; + return { + text: mapped.text.slice(start), + starts: mapped.starts.slice(start), + ends: mapped.ends.slice(start), + }; +} + +/** The language of the file `cat > file` or `tee file` writes its stdin to. */ +function writtenFileLanguage( + name: string, + command: Command, + args: ReadonlyArray, +): string | undefined { + let path: string | undefined; + if (name === "cat") { + for (const redirect of command.redirects) { + if ( + redirect.type === "Redirect" && + [">", ">>", ">|", "&>", "&>>"].includes(redirect.operator) && + isDescriptor(redirect.descriptor, 1) + ) { + path = redirect.target?.value; + } + } + } else if (name === "tee") { + path = args.find((arg) => !arg.value.startsWith("-"))?.value; + } + if (!path) return undefined; + const language = getFiletypeFromFileName(path); + return language === "text" ? undefined : language; +} + +function delimiterLanguage(hereDoc: HereDoc): string | undefined { + const name = hereDoc.delimiter?.value.toLowerCase(); + return name && Object.hasOwn(DELIMITER_LANGUAGES, name) ? DELIMITER_LANGUAGES[name] : undefined; +} + +/** A heredoc body as written; the interpreter reads its characters one to one. */ +function hereDocText(hereDoc: HereDoc, source: string): MappedText { + const { pos, end } = hereDoc.body; + const starts = Array.from({ length: end - pos }, (_, index) => pos + index); + return { + text: source.slice(pos, end), + starts, + ends: starts.map((start) => start + 1), + }; +} + +/** + * A word's value with the source span of each character: quotes removed and + * escapes resolved, the way the program receives it. Expansions such as + * `$HOME` stay as written. Returns undefined for spellings it cannot map. + */ +function wordText(word: Word, source: string): MappedText | undefined { + const builder = new MappedTextBuilder(); + const parts: ReadonlyArray = word.parts ?? [ + { type: "Literal", pos: word.pos, end: word.end, text: word.text, value: word.value }, + ]; + for (const part of parts) { + switch (part.type) { + case "Literal": + if (!builder.addEscaped(part, false)) return undefined; + break; + case "SingleQuoted": + builder.addRaw(part.value, part.pos + 1); + break; + case "DoubleQuoted": + case "LocaleString": + for (const child of part.parts) { + if (!builder.addDoubleQuotedChild(child)) return undefined; + } + break; + case "AnsiCQuoted": + // $'…' escapes such as \x41 do not map character for character. + if (part.value !== part.text.slice(2, -1)) return undefined; + builder.addRaw(part.value, part.pos + 2); + break; + default: + builder.addRaw(source.slice(part.pos, part.end), part.pos); + } + } + return builder.build(); +} + +class MappedTextBuilder { + private text = ""; + private readonly starts: number[] = []; + private readonly ends: number[] = []; + + addRaw(text: string, pos: number) { + for (let index = 0; index < text.length; index += 1) this.push(text[index]!, pos + index, 1); + } + + addDoubleQuotedChild(child: DoubleQuotedChild): boolean { + if (child.type === "Literal") return this.addEscaped(child, true); + this.addRaw(child.text, child.pos); + return true; + } + + /** Adds a literal, resolving backslash escapes; false when the result disagrees with the parser. */ + addEscaped(literal: { pos: number; text: string; value: string }, doubleQuoted: boolean) { + const before = this.text.length; + const { text, pos } = literal; + for (let index = 0; index < text.length; index += 1) { + const next = text[index + 1]; + if ( + text[index] === "\\" && + next !== undefined && + (!doubleQuoted || '$`"\\\n'.includes(next)) + ) { + // A backslash-newline joins lines and spells nothing. + if (next !== "\n") this.push(next, pos + index, 2); + index += 1; + } else { + this.push(text[index]!, pos + index, 1); + } + } + return this.text.slice(before) === literal.value; + } + + build(): MappedText { + return { text: this.text, starts: this.starts, ends: this.ends }; + } + + private push(character: string, start: number, length: number) { + this.text += character; + this.starts.push(start); + this.ends.push(start + length); + } +} diff --git a/packages/client-runtime/src/work-log/commandLabel.test.ts b/packages/client-runtime/src/work-log/commandLabel.test.ts index e048ef35d7c1..2ba38fd37a57 100644 --- a/packages/client-runtime/src/work-log/commandLabel.test.ts +++ b/packages/client-runtime/src/work-log/commandLabel.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from "vite-plus/test"; -import { commandDisplayText, commandProgramName } from "./commandLabel.ts"; +import { + commandDisplayText, + commandHighlightLanguage, + commandProgramName, +} from "./commandLabel.ts"; describe("commandProgramName", () => { it.each([ @@ -493,3 +497,107 @@ describe("commandDisplayText", () => { expect(commandDisplayText(input)).toBe(expected); }); }); + +describe("commands recorded from each provider", () => { + // Inputs are copied from the recorded replay transcripts in + // apps/server/src/orchestration-v2/testkit/fixtures (turn_interrupt_mid_tool, + // todo_list, grok_background_bash and + // opencode_running_child_approval). In turn_interrupt_mid_tool every provider + // ran the same script, and only Codex wraps it in a login shell. + const interruptScript = `node -e "console.log('interrupt fixture tool started'); setTimeout(() => {}, 30000)"`; + it.each([ + ["Claude", interruptScript, interruptScript], + ["Cursor", interruptScript, interruptScript], + ["Pi", interruptScript, interruptScript], + [ + "Codex", + `/bin/bash -lc "node -e \\"console.log('interrupt fixture tool started'); setTimeout(() => {}, 30000)\\""`, + interruptScript, + ], + [ + "Codex", + "/bin/bash -lc 'cat package.json && cat tsconfig.json'", + "cat package.json && cat tsconfig.json", + ], + [ + "Grok", + "for i in 1 2 3; do sleep 8; echo tock $i; done", + "for i in 1 2 3; do sleep 8; echo tock $i; done", + ], + [ + "OpenCode", + "printf 'running child approval' > running-child.txt", + "printf 'running child approval' > running-child.txt", + ], + ])("%s shows the script it ran: %s", (_provider, input, script) => { + expect(commandDisplayText(input)).toBe(script); + expect(commandHighlightLanguage(commandDisplayText(input))).toBe("shellscript"); + }); +}); + +describe("commandDisplayText with redacted Codex commands", () => { + // Codex replaces secrets in command text before sending it + // (codex-rs/secrets/src/sanitizer.rs). Usually the quoting survives. + it("unwraps a redacted command whose quoting is intact", () => { + expect( + commandDisplayText( + `/bin/zsh -lc 'curl -H "Authorization: Bearer [REDACTED_SECRET]" https://example.com'`, + ), + ).toBe('curl -H "Authorization: Bearer [REDACTED_SECRET]" https://example.com'); + }); + + // Its secret-assignment pattern can also swallow the backslash of an escaped + // quote: `token=config[\"apiToken\"]` becomes `token=[REDACTED_SECRET]"apiToken\"]`. + // The bare quote ends the -lc argument early, so where the script ends is unknown. + it("keeps the wrapper when redaction broke the quoting", () => { + const input = `/bin/zsh -lc "python3 -c 'token=[REDACTED_SECRET]"apiToken\\"]; print(token)'"`; + expect(commandDisplayText(input)).toBe(input); + }); +}); + +describe("commandDisplayText control characters", () => { + it.each([ + ["printf '\u001b[31mred\u001b[0m'", "printf '␛[31mred␛[0m'"], + ["zsh -lc 'printf \"\u001b[1m\"'", 'printf "␛[1m"'], + ["echo bell\u0007 delete\u007f", "echo bell␇ delete␡"], + ["printf 'a\\tb'\r\necho done", "printf 'a\\tb'\r\necho done"], + ["cat <<'EOF'\n\tindented\nEOF", "cat <<'EOF'\n\tindented\nEOF"], + ])("shows %j as %j", (input, expected) => { + expect(commandDisplayText(input)).toBe(expected); + }); +}); + +describe("commandHighlightLanguage", () => { + it.each([ + [ + '"C:\\Program Files\\PowerShell\\7\\pwsh.exe" -NoProfile -Command "Get-ChildItem -Recurse"', + "powershell", + ], + [ + 'powershell.exe -NoProfile -ExecutionPolicy Bypass -File "C:\\work\\scripts\\doctor.ps1"', + "powershell", + ], + ["& pwsh -c 'Get-Date'", "powershell"], + ["PWSH.EXE -Command Get-Date", "powershell"], + ["pwsh-preview -c 'Get-Date'", "shellscript"], + ["git status; pwsh -c 'Get-Date'", "shellscript"], + ["cat <<'EOF' > notes.txt\npwsh\nEOF", "shellscript"], + ["", "shellscript"], + // Windows agents also run PowerShell directly, without a pwsh wrapper. + ["Get-Content package.json | Select-String version", "powershell"], + ["$env:CI='1'; npm test", "powershell"], + ["$tmp = Join-Path $env:TEMP repo; git clone example", "powershell"], + ['"=== CHECK FILE ==="; Get-Content file.txt', "powershell"], + ["& 'C:\\Python312\\python.exe' -c 'print(1)'", "powershell"], + ["Install-Module Pester -Scope CurrentUser", "powershell"], + ["ConvertTo-Json @{ a = 1 }", "powershell"], + ["update-alternatives --list java", "shellscript"], + ["install-info --version", "shellscript"], + ["Make-Thing now", "shellscript"], + ["echo Get-Content; git status", "shellscript"], + ["FOO=1 npm test", "shellscript"], + ["echo $HOME && ls", "shellscript"], + ])("%s", (command, language) => { + expect(commandHighlightLanguage(command)).toBe(language); + }); +}); diff --git a/packages/client-runtime/src/work-log/commandLabel.ts b/packages/client-runtime/src/work-log/commandLabel.ts index c3df952f477d..12557bdc8698 100644 --- a/packages/client-runtime/src/work-log/commandLabel.ts +++ b/packages/client-runtime/src/work-log/commandLabel.ts @@ -1368,8 +1368,31 @@ export function commandProgramName(command: string, depth = 0): string | null { return commandProgramNameInternal(command, depth, "shell", MAX_COMMAND_SEGMENTS); } -/** Removes a plain shell -c wrapper for display; callers retain the original for details. */ +// Escape bytes and other control characters render as invisible gaps; display +// shows them as Unicode control pictures (ESC becomes ␛) instead. Tab, newline +// and carriage return lay out as whitespace, so CRLF scripts stay unmarked. +// oxlint-disable-next-line no-control-regex +const CONTROL_CHARACTERS = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/gu; + +/** + * The command as rows show it: a plain shell -c wrapper removed and control + * characters made visible. Callers retain the original for details. + */ export function commandDisplayText(command: string): string { + return withVisibleControlCharacters(withoutShellWrapper(command)); +} + +/** Replaces each control character with its control picture; the length does not change. */ +export function withVisibleControlCharacters(text: string): string { + return text.replace(CONTROL_CHARACTERS, (character) => + character === "\u007f" ? "␡" : String.fromCharCode(0x2400 + character.charCodeAt(0)), + ); +} + +// A wrapper whose quoting does not balance is kept: the script's end cannot be +// known, which happens when Codex's secret redaction removes the backslash of +// an escaped quote inside `-lc "…"`. +function withoutShellWrapper(command: string): string { const trimmed = command.trim(); const split = splitFirstShellCommand(trimmed); if (split.remainingCommand !== null) return trimmed; @@ -1384,3 +1407,55 @@ export function commandDisplayText(command: string): string { if (scriptIndex === null || scriptIndex !== tokens.length - 1) return trimmed; return tokens[scriptIndex]?.trim() || trimmed; } + +const POWERSHELL_PROGRAMS = new Set(["powershell", "pwsh"]); + +// PowerShell's approved verbs (`Get-Verb`), plus the ForEach, Where, Sort and +// Tee of its built-in *-Object cmdlets. Cmdlets match only capitalized, as +// agents write them: lowercase `update-grub` or `install-info` are Linux tools. +const POWERSHELL_VERBS = new Set( + `Add Approve Assert Backup Block Build Checkpoint Clear Close Compare Complete Compress Confirm Connect Convert ConvertFrom ConvertTo Copy Debug Deny Deploy Disable Disconnect Dismount Edit Enable Enter Exit Expand Export Find ForEach Format Get Grant Group Hide Import Initialize Install Invoke Join Limit Lock Measure Merge Mount Move New Open Optimize Out Ping Pop Protect Publish Push Read Receive Redo Register Remove Rename Repair Request Reset Resize Resolve Restart Restore Resume Revoke Save Search Select Send Set Show Skip Sort Split Start Step Stop Submit Suspend Switch Sync Tee Test Trace Unblock Undo Uninstall Unlock Unprotect Unpublish Unregister Update Use Wait Watch Where Write`.split( + " ", + ), +); + +function isPowerShellCmdlet(word: string): boolean { + const cmdlet = /^([A-Z][a-z]+(?:[A-Z][a-z]+)?)-[A-Z][A-Za-z]*$/u.exec(word); + return cmdlet !== null && POWERSHELL_VERBS.has(cmdlet[1]!); +} + +/** + * The grammar to highlight a command with: PowerShell when pwsh or powershell + * runs it, or when it is written in PowerShell, as Windows agents run it. + */ +export function commandHighlightLanguage(command: string): "powershell" | "shellscript" { + const program = /^(?:&\s*)?(?:"([^"]*)"|'([^']*)'|(\S+))/u.exec(command.trim()); + const name = (program?.[1] ?? program?.[2] ?? program?.[3] ?? "") + .split(/[\\/]/u) + .at(-1) + ?.toLowerCase() + .replace(/\.exe$/u, ""); + if (name && POWERSHELL_PROGRAMS.has(name)) return "powershell"; + return isPowerShellScript(command) ? "powershell" : "shellscript"; +} + +/** Whether any statement starts like PowerShell and never like POSIX shell. */ +function isPowerShellScript(command: string): boolean { + let rest: string | null = command.trim(); + // A leading call operator; POSIX shell cannot start a command with `&`. + if (/^&\s*\S/u.test(rest)) return true; + for (let segment = 0; rest && segment < MAX_COMMAND_SEGMENTS; segment += 1) { + const { firstCommand, remainingCommand } = splitFirstShellCommand(rest); + const statement = firstCommand.trim(); + if ( + // `$env:NAME` or `$name = value`; neither parses as POSIX shell. + /^\$(?:env|global|local|script):/iu.test(statement) || + /^\$[A-Za-z_]\w*\s+=/u.test(statement) || + isPowerShellCmdlet(statement.match(/^\S+/u)?.[0] ?? "") + ) { + return true; + } + rest = remainingCommand; + } + return false; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 02883a8b6536..447b1c9e77f9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -755,6 +755,9 @@ importers: tailwind-merge: specifier: ^3.4.0 version: 3.6.0 + unbash: + specifier: ^5.0.0 + version: 5.0.0 zustand: specifier: ^5.0.11 version: 5.0.14(@types/react@19.3.0)(react@19.2.6)(use-sync-external-store@1.6.0(react@19.2.6)) @@ -10825,6 +10828,10 @@ packages: resolution: {integrity: sha512-FoSOKV7NEofQSkAefMVHam4ZPKYMxjAydxiV72UFEDNV/YofxjGfiZ2A9pZjdL/lRJzTjcu4PABo1JYJX8N5iQ==} engines: {node: '>=14'} + unbash@5.0.0: + resolution: {integrity: sha512-RYNm/ISFtflWpIcXTPHHLJZ0mqzInav+L9ke9PcL+hHsSf9+Fd/syPb614Mlqs/O3AYgjnvxDOFl0a+zVqi1/w==} + engines: {node: '>=14'} + uncrypto@0.1.3: resolution: {integrity: sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==} @@ -21998,6 +22005,8 @@ snapshots: unbash@4.0.11: {} + unbash@5.0.0: {} + uncrypto@0.1.3: {} undici-types@7.16.0: {}