diff --git a/web/src/fixtures/panes/README.md b/web/src/fixtures/panes/README.md index 2b5570baf..6d54071a9 100644 --- a/web/src/fixtures/panes/README.md +++ b/web/src/fixtures/panes/README.md @@ -519,6 +519,11 @@ Grok's composer is a rounded box at the tail: `╭─…─╮` / `│ ❯ … | `grok--output-scrollbar.txt` | Grok Build 1.0.46 on macOS, 2026-10-08: tail of the live browser-send capture, from the code reply through the composer, byte-faithful except one status line, whose text was replaced with neutral text of the same width; its ANSI styling is unchanged. The code keeps its rgb(28,28,28) surface; the right-edge track is a separate `█` segment with foreground and background rgb(25,25,25), preceded by uncoloured rgb(20,20,20) canvas padding. A dark block following coloured message text must remain. Its empty viewport rows and full terminal-width padding must not inflate the wrapped phone mirror. Original screen rows remain intact for grammar and guard probes | `done` | | `grok--working.txt` | Mid-turn; empty box; working hint row under the box | `working` | | `grok--startup.txt` | Fresh-session welcome screen: banner box (logo, menu) above an idle composer whose under-box row is the bare `[stable]` channel chip, not the hint bar. composerReady must be TRUE. Byte-faithful `format:ansi` 2026-08-22 | `idle` | +| `grok--welcome-card.txt` | Grok Build 1.0.52, signed in with an API key: the changelog welcome card sits right on top of the composer with no `Tip:` row between, and the under-box row reads `Logged in with API key │ [stable]`. composerReady must be TRUE. Visible-screen `format:ansi` read 2026-10-15 in a throwaway Herdr session; the cwd row became `/tmp/collie-grok-sandbox` and the model label `mini` | `idle` | +| `grok--slash-popup-exact.txt` | Grok Build 1.0.52, `/statusline` typed: the slash-command popup between two rules right on top of the composer, `❯ /statusline` highlighted. The box stays live and the draft reads `/statusline`. Visible-screen read 2026-10-15; a local hook notice above was blanked and the cwd row became `/tmp/collie-grok-sandbox` | `idle` | +| `grok--model-arg-hint.txt` | Grok Build 1.0.52 after the harness bar's Model button: the model list between two rules over the composer, and the box holding `❯ /model [window] [effort]`, the hint in grey `rgb(108,108,108)`. The draft reads `/model`; the list is not a slash popup, so composerReady is FALSE. Visible-screen read 2026-10-15 by the harness canary's bar scenario; a local hook notice above was blanked, the cwd row became `/tmp/collie-grok-sandbox` at the same width, and the model label `mini` | `idle` | +| `grok--slash-popup-partial.txt` | Hand-built from the capture above: the box holds `/stat` while `❯ /statusline` stays highlighted, the shape a partial command takes (live, `/stat` highlighted another command). Enter would run the highlighted row, so the draft must not read `/stat` | `idle` | +| `grok--trust-prompt.txt` | Grok Build 1.0.52 started in an untrusted folder that holds a project skill (`.agents/skills`): under the logo, `Do you trust the contents of this directory?`, the directory, a two-row warning, then `Yes, proceed y` and `No, quit n`, the version chip alone below. Lifts as a trust card, `y` and `n` each sent alone: live 2026-10-15, `n` quit to the shell and `y` went on to the composer and wrote the folder into the throwaway `trusted_folders.toml`. A folder with nothing for Grok to load showed no prompt. Recent-buffer read in a throwaway Herdr session with a moved GROK_HOME; the truncated scratch path became `/tmp/collie-grok-sandbox` at the same width | `blocked` | | `grok--done.txt` | Square user-message bubble ABOVE an idle composer — the bubble must survive the strip | `idle` | | `grok--user-bubble.txt` | Torn frame: square bubble, no composer. `locateComposer` must return null | — | | `grok--permission-rm.txt` | Bash `rm` permission card at the tail; `●` on option 1 (always-approve). Composer replaced. Byte-faithful `format:ansi` 2026-08-21 | `blocked` | diff --git a/web/src/fixtures/panes/grok--model-arg-hint.txt b/web/src/fixtures/panes/grok--model-arg-hint.txt new file mode 100644 index 000000000..54f41f2f9 --- /dev/null +++ b/web/src/fixtures/panes/grok--model-arg-hint.txt @@ -0,0 +1,40 @@ +  + master /tmp/collie-grok-sandbox  ⠋ MCP (2/2)  │ 1.3K / 200K │ [Dashboard]  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────3─  +  ❯ Grok 4.6 SpaceXAI's latest frontier model   +  Grok 4.5   +  mini (current)   + ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────  + ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮  + │ ❯ /model  [window] [effort] │  + ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────── mini ─╯  +  + Enter:send │ Shift+Enter/Opt+Enter:newline │ Shift+Tab:mode │ Ctrl+.:shortcuts   +  diff --git a/web/src/fixtures/panes/grok--slash-popup-exact.txt b/web/src/fixtures/panes/grok--slash-popup-exact.txt new file mode 100644 index 000000000..506bfba5d --- /dev/null +++ b/web/src/fixtures/panes/grok--slash-popup-exact.txt @@ -0,0 +1,40 @@ +  + master /tmp/collie-grok-sandbox  1.3K / 256K │ [Dashboard]  +  + + +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + Help improve Grok [Opt out] [Opt in]  + Off by default. Opt-in to allow SpaceXAI to retain coding data, e.g., prompts, traces, &  + ─────────────────────────────────────────────────────────────────────────────────────────────────────────────────1─  +  ❯ /statusline Configure the Grok Build status line.   + ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────  + ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮  + │ ❯ /statusline │  + ╰────────────────────────────────────────────────────────────────────────────────────────── Grok 4.7 Fast (high) ─╯  +  + Enter:send │ Shift+Enter/Opt+Enter:newline │ Shift+Tab:mode │ Ctrl+.:shortcuts   +  \ No newline at end of file diff --git a/web/src/fixtures/panes/grok--slash-popup-partial.txt b/web/src/fixtures/panes/grok--slash-popup-partial.txt new file mode 100644 index 000000000..696bc75c1 --- /dev/null +++ b/web/src/fixtures/panes/grok--slash-popup-partial.txt @@ -0,0 +1,40 @@ +  + master /tmp/collie-grok-sandbox  1.3K / 256K │ [Dashboard]  +  + + +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + Help improve Grok [Opt out] [Opt in]  + Off by default. Opt-in to allow SpaceXAI to retain coding data, e.g., prompts, traces, &  + ─────────────────────────────────────────────────────────────────────────────────────────────────────────────────1─  +  ❯ /statusline Configure the Grok Build status line.   + ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────  + ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮  + │ ❯ /stat  │  + ╰────────────────────────────────────────────────────────────────────────────────────────── Grok 4.7 Fast (high) ─╯  +  + Enter:send │ Shift+Enter/Opt+Enter:newline │ Shift+Tab:mode │ Ctrl+.:shortcuts   +  \ No newline at end of file diff --git a/web/src/fixtures/panes/grok--trust-prompt.txt b/web/src/fixtures/panes/grok--trust-prompt.txt new file mode 100644 index 000000000..cb9562d84 --- /dev/null +++ b/web/src/fixtures/panes/grok--trust-prompt.txt @@ -0,0 +1,40 @@ +  + /t/collie-grok-sandbox/trustprobe  +  +  +  +  + ⠀⠀⠀⠀⠀⠀⣀⣀⡀⠀⠀⠀⢀⠄  + ⠀⠀⠀⣠⣾⠿⠛⠛⠛⠛⢀⡴⠁⠀  + ⠀⠀⣼⡟⠁⠀⠀⠀⢀⡴⠻⣿⡀⠀  + ⠀⠀⣿⡇⠀⠀⠀⠔⠁⠀⠀⣿⡇⠀  + ⠀⠀⢹⣷⠀⠀⠀⠀⠀⢀⣴⡿⠀⠀  + ⠀⢀⠞⠁⠠⢶⣶⣶⣶⠿⠋⠀⠀⠀  + ⠐⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀  +  +  + Do you trust the contents of this directory?  +/tmp/collie-grok-sandbox  +  + Grok Build may run or modify contents in this directory,  + posing security risks.  +  + Yes, proceed y  + No, quit n  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + Grok Build 1.0.52 [stable]  +  \ No newline at end of file diff --git a/web/src/fixtures/panes/grok--welcome-card.txt b/web/src/fixtures/panes/grok--welcome-card.txt new file mode 100644 index 000000000..2bc442c5e --- /dev/null +++ b/web/src/fixtures/panes/grok--welcome-card.txt @@ -0,0 +1,40 @@ +  + /tmp/collie-grok-sandbox  +  +  +  +  +  + ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────╮  + │ │  + │ ⠀⠀⠀⠀⠀⠀⣀⣀⡀⠀⠀⠀⢀⠄ Grok Build 1.0.52 │  + │ ⠀⠀⠀⣠⣾⠿⠛⠛⠛⠛⢀⡴⠁⠀ │  + │ ⠀⠀⣼⡟⠁⠀⠀⠀⢀⡴⠻⣿⡀⠀ Changelog │  + │ ⠀⠀⣿⡇⠀⠀⠀⠔⠁⠀⠀⣿⡇⠀ │  + │ ⠀⠀⢹⣷⠀⠀⠀⠀⠀⢀⣴⡿⠀⠀  • Rex terminal support: Grok now reports its status (working, waiting for you, done, or… │  + │ ⠀⢀⠞⠁⠠⢶⣶⣶⣶⠿⠋⠀⠀⠀  • Tool call density improvements: expanded tool call groups now stay expanded as new ca… │  + │ ⠐⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀  • Grep searches that time out or fail now show an error instead of appearing as empty r… │  + │ │  + │ New worktree ctrl+w │  + │ Resume session ctrl+r │  + │ Changelog │  + │ Quit ctrl+q │  + │ │  + ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────╯  +  +  +  +  +  +  +  +  +  +  +  + ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮  + │ ❯  │  + ╰────────────────────────────────────────────────────────────────────────────────────────────────────────── mini ─╯  +  + Logged in with API key │ [stable]  +  \ No newline at end of file diff --git a/web/src/lib/harness/grok.test.ts b/web/src/lib/harness/grok.test.ts index 279bbfdf6..f1b1e7c9a 100644 --- a/web/src/lib/harness/grok.test.ts +++ b/web/src/lib/harness/grok.test.ts @@ -45,6 +45,7 @@ const PINNED = [ "grok--draft-single.txt", "grok--draft-wrapped.txt", "grok--fresh-idle.txt", + "grok--model-arg-hint.txt", "grok--model-effort-moved.txt", "grok--model-effort.txt", "grok--model-picker-moved.txt", @@ -62,8 +63,12 @@ const PINNED = [ "grok--reply-table-highlighted.txt", "grok--reply-table-scrollbar.txt", "grok--reporter-294-draft-newline-hint.txt", + "grok--slash-popup-exact.txt", + "grok--slash-popup-partial.txt", "grok--startup.txt", + "grok--trust-prompt.txt", "grok--user-bubble.txt", + "grok--welcome-card.txt", "grok--working.txt", ]; @@ -90,6 +95,7 @@ const DIALOG = [ "grok--plan-approval.txt", "grok--plan-request-changes.txt", "grok--plan-tab-prompt.txt", + "grok--trust-prompt.txt", ]; const ownFixtures = DIALOG; @@ -117,13 +123,17 @@ describe("the grok corpus", () => { describe("composerReady — the gate the reply path pre-flights on", () => { it.each( - neutralFixtures.filter((f) => f !== "grok--user-bubble.txt" && !f.includes("ask-multi")), + neutralFixtures.filter( + (f) => f !== "grok--user-bubble.txt" && f !== "grok--model-arg-hint.txt" && !f.includes("ask-multi"), + ), )("%s: the composer is on screen ⇒ true", (name) => { const lines = splitLines(parseAnsi(readFileSync(join(PANES_DIR, name), "utf8"))); expect(grokAdapter.composerReady!(lines)).toBe(true); }); - it.each(["grok--user-bubble.txt", "grok--ask-multi.txt", "grok--ask-multi-checked.txt", ...DIALOG])( + // The model list Grok opens over the box after `/model` is not a slash popup, so the box fails + // closed there: a phone reply would append to the `/model` it holds. + it.each(["grok--user-bubble.txt", "grok--ask-multi.txt", "grok--ask-multi-checked.txt", "grok--model-arg-hint.txt", ...DIALOG])( "%s: a modal or torn frame ⇒ false", (name) => { const lines = splitLines(parseAnsi(readFileSync(join(PANES_DIR, name), "utf8"))); @@ -553,6 +563,44 @@ describe("grokBuildBlocks", () => { expect(text).toContain("Grok 4.6 is here!"); }); + it("Grok 1.0.52's welcome card right on top of the box does not hold the keyboard", () => { + // The changelog card comes with no tip row under it, so it sits directly on the composer, where + // an unknown rounded widget would fail closed. Typing lands in the box and the card goes (live, + // 2026-10-15). + const lines = splitLines(parseAnsi(readFileSync(join(PANES_DIR, "grok--welcome-card.txt"), "utf8"))); + expect(grokAdapter.composerReady!(lines)).toBe(true); + expect(grokAdapter.extractInputDraft(lines)).toBeNull(); + }); + + it("the slash-command popup above the box leaves the box live, and an exact command verifies", () => { + // Grok paints its `/` completion between two rules right on top of the composer (1.0.52). Enter + // runs the highlighted row, so the typed command verifies when that row is the command itself. + const lines = splitLines(parseAnsi(readFileSync(join(PANES_DIR, "grok--slash-popup-exact.txt"), "utf8"))); + expect(grokAdapter.composerReady!(lines)).toBe(true); + expect(grokAdapter.extractInputDraft(lines)).toBe("/statusline"); + }); + + it("partial input under the popup does not verify, since Enter would run the highlighted row", () => { + const lines = splitLines(parseAnsi(readFileSync(join(PANES_DIR, "grok--slash-popup-partial.txt"), "utf8"))); + expect(grokAdapter.composerReady!(lines)).toBe(true); + expect(grokAdapter.extractInputDraft(lines)).not.toBe("/stat"); + }); + + it("a slash command's grey argument hint is not part of the draft", () => { + // The bar's Model button leaves `❯ /model [window] [effort]` in the box over the model + // list (1.0.52, live 2026-10-15); the phone offered the hint back as a terminal draft to take over. + const lines = splitLines(parseAnsi(readFileSync(join(PANES_DIR, "grok--model-arg-hint.txt"), "utf8"))); + expect(grokAdapter.extractInputDraft(lines)).toBe("/model"); + }); + + it("keeps the same words when typed, since typed text is not the hint's grey", () => { + const raw = readFileSync(join(PANES_DIR, "grok--model-arg-hint.txt"), "utf8").replace( + "\u001b[38;2;108;108;108m\u001b[48;2;20;20;20m", + "\u001b[38;2;225;225;225m\u001b[48;2;20;20;20m", + ); + expect(grokAdapter.extractInputDraft(splitLines(parseAnsi(raw)))).toBe("/model [window] [effort]"); + }); + it("blank rows render unpainted in every block; glyph rows keep their backgrounds", () => { const lines = splitLines(parseAnsi(readFileSync(join(PANES_DIR, "grok--permission-edit.txt"), "utf8"))); // The live frame paints base-coat colour across fully blank rows (the gray-stripe bug) and @@ -583,3 +631,34 @@ describe("grokBuildBlocks", () => { expect(lines.map(lineText).join("\n")).toContain("╭"); }); }); + +describe("grok: the folder trust prompt lifts as a trust card (1.0.52)", () => { + const lines = () => splitLines(parseAnsi(readFileSync(join(PANES_DIR, "grok--trust-prompt.txt"), "utf8"))); + const promptOf = (rows: ReturnType) => { + const block = grokAdapter.buildBlocks(rows).find((b) => b.kind === "prompt-select"); + return block?.kind === "prompt-select" ? block.prompt : null; + }; + + it("offers the two printed keys, y and n, as the trust family", () => { + const prompt = promptOf(lines()); + expect(prompt?.family).toBe("trust"); + expect(prompt?.question).toBe("Do you trust the contents of this directory?"); + expect(prompt?.options.map((o) => [o.label, o.keys.join("+")])).toEqual([ + ["Yes, proceed", "y"], + ["No, quit", "n"], + ]); + }); + + it("starts the card at the first option, so the directory and the warning stay in the mirror", () => { + const [raw, card] = grokAdapter.buildBlocks(lines()); + expect(card?.kind === "prompt-select" && lineText(card.lines[0]!).trim()).toMatch(/^Yes, proceed\s+y$/); + const mirror = raw?.kind === "raw" ? raw.lines.map((l) => lineText(l)).join("\n") : ""; + expect(mirror).toContain("/tmp/collie-grok-sandbox"); + expect(mirror).toContain("posing security risks."); + }); + + it("does not lift the options without the question above them", () => { + const rows = lines().map((l) => lineText(l).replace("Do you trust the contents of this directory?", "")); + expect(promptOf(splitLines(parseAnsi(rows.join("\n"))))).toBeNull(); + }); +}); diff --git a/web/src/lib/harness/grok/chrome.ts b/web/src/lib/harness/grok/chrome.ts index 2873a3793..af5ed31b7 100644 --- a/web/src/lib/harness/grok/chrome.ts +++ b/web/src/lib/harness/grok/chrome.ts @@ -280,6 +280,29 @@ function draftLineText(line: StyledLine): string { return text; } +// After a slash command Grok paints its argument hint in grey (`❯ /model [window] [effort]`, +// 1.0.52, live 2026-10-15), and it stays while the model list is open. Read as draft, the phone +// offered the hint back as a terminal draft to take over. Only that shape, in that grey, as the +// row's last text, so the same words typed by the user (typed-text colour) stay in the draft. +const ARG_HINT = /^(?:<[^<>]+>|\[[^[\]]+\])(?:\s+(?:<[^<>]+>|\[[^[\]]+\]))*$/; + +function withoutArgumentHint(line: StyledLine, text: string): string { + let offset = 0; + let hint: { at: number; length: number } | null = null; + for (const segment of line.segments) { + const body = segment.text.trim(); + if (body !== "" && body !== "│") { + hint = + segment.style.color === "rgb(108,108,108)" && ARG_HINT.test(body) + ? { at: offset + segment.text.indexOf(body), length: body.length } + : null; + } + offset += segment.text.length; + } + if (hint === null || !/❯\s*\//.test(text.slice(0, hint.at))) return text; + return text.slice(0, hint.at) + " ".repeat(hint.length) + text.slice(hint.at + hint.length); +} + /** * The user's draft stranded in the composer. Grok writes it on the `│ ❯ … │` row and wraps onto * indented continuation rows below. Fragments join with a single space (soft wrap). Empty box → null. @@ -296,7 +319,8 @@ export function extractInputDraft(lines: StyledLine[]): string | null { const box = locateComposer(lines); if (box === null) return null; const parts: string[] = []; - const prompt = composerPromptText(draftLineText(lines[box.firstDraftRow]!)); + const first = lines[box.firstDraftRow]!; + const prompt = composerPromptText(withoutArgumentHint(first, draftLineText(first))); if (prompt === null) return null; parts.push(prompt.trim()); for (let i = box.firstDraftRow + 1; i < box.bottom; i++) { @@ -304,7 +328,14 @@ export function extractInputDraft(lines: StyledLine[]): string | null { } const draft = parts.filter((p) => p.length > 0).join(" "); - return draft.length === 0 ? null : draft; + if (draft.length === 0) return null; + // Under the slash popup, Enter runs the HIGHLIGHTED row. The typed text verifies only when that + // row is the typed command; on partial input (`/stat` over `❯ /statusline`) the highlighted command + // is folded in, so the reply guard cannot match and refuses rather than run a command nobody typed. + const texts = lines.map((l) => rstrip(lineText(l))); + const pointed = popupOnComposer(texts, box.top); + if (pointed !== null && pointed !== draft.split(/\s+/)[0]) return `${draft} ${pointed}`; + return draft; } /** Whether Grok's free-text composer is on screen at the tail. */ @@ -319,6 +350,56 @@ export function hasComposer(lines: StyledLine[]): boolean { // not captured yet. const USER_BUBBLE_EDGE = /^\s*[┌└]/; +// Grok's welcome card: a rounded box holding the logo and its startup menu, `New worktree`, +// `Resume session`, `Changelog`, `Quit` (every startup capture since 1.0.4x). It does not own the +// keyboard: typing lands in the box below and the card goes (live, Grok Build 1.0.52, 2026-10-15). +// Older builds printed a `Tip:` row between it and the box; the 1.0.52 changelog card sits right on +// top of the box, where an unknown rounded widget fails closed, so it is named here. +const WELCOME_MENU = [/\bNew worktree\b/, /\bResume session\b/, /\bQuit\b/]; +const ROUNDED_BOTTOM = /^\s*╰/; +const ROUNDED_TOP = /^\s*╭/; +const WELCOME_SCAN_ROWS = 40; + +// Grok's slash-command completion popup (1.0.52, live 2026-10-15): its rows sit between two rules +// directly on top of the composer, the highlighted one led by `❯ /`, the rest by spaces and `/`. The +// box below stays live: typing lands in it. A rule-bounded run holding a `❯ /` row is what tells it +// from the model picker, whose pointer rows name a model, not a slash command. +const POPUP_RULE = /^\s*─+(?:\d+─+)?$/; +const POPUP_POINTED = /^\s*❯ (\/\S+)/; +const POPUP_ROWS = 40; + +/** The highlighted command of a slash popup whose bottom rule is row `bottom`, or null for no popup. */ +function slashPopupPointed(texts: string[], bottom: number): string | null { + if (!POPUP_RULE.test(texts[bottom]!)) return null; + let pointed: string | null = null; + for (let row = bottom - 1; row >= 0 && bottom - row <= POPUP_ROWS; row--) { + const t = texts[row]!; + if (POPUP_RULE.test(t)) return pointed; + pointed = POPUP_POINTED.exec(t)?.[1] ?? pointed; + } + return null; +} + +/** The slash popup's highlighted command when one sits right on top of the composer at `top`. */ +function popupOnComposer(texts: string[], top: number): string | null { + for (let i = top - 1; i >= 0; i--) { + if (isBlank(texts[i]!)) continue; + return slashPopupPointed(texts, i); + } + return null; +} + +/** Whether the rounded box closing on row `bottom` is Grok's welcome card. */ +function closesWelcomeCard(texts: string[], bottom: number): boolean { + if (!ROUNDED_BOTTOM.test(texts[bottom]!)) return false; + for (let top = bottom - 1; top >= 0 && bottom - top <= WELCOME_SCAN_ROWS; top--) { + if (!ROUNDED_TOP.test(texts[top]!)) continue; + const inner = texts.slice(top + 1, bottom); + return WELCOME_MENU.every((row) => inner.some((t) => row.test(t))); + } + return false; +} + /** * Whether a phone reply would reach Grok's composer rather than a widget sitting on top of it. * @@ -339,7 +420,7 @@ export function composerReady(lines: StyledLine[]): boolean { const t = texts[i]!; if (isBlank(t)) continue; if (USER_BUBBLE_EDGE.test(t)) return true; - if (opensBox(t)) return false; + if (opensBox(t)) return closesWelcomeCard(texts, i) || slashPopupPointed(texts, i) !== null; return true; } return true; diff --git a/web/src/lib/harness/grok/index.ts b/web/src/lib/harness/grok/index.ts index e2a252329..ec672832b 100644 --- a/web/src/lib/harness/grok/index.ts +++ b/web/src/lib/harness/grok/index.ts @@ -42,6 +42,7 @@ import { detectAskRegion } from "./ask"; import { detectPermissionRegion } from "./permission"; import { detectPlanMenuRegion } from "./plan-menu"; import { detectModelPickerRegion } from "./model-picker"; +import { detectTrustRegion } from "./trust"; export function grokBuildBlocks(lines: StyledLine[]): Block[] { // Drop Grok's full-screen theme-canvas paint before any block is built, so every rendered @@ -88,6 +89,15 @@ export function grokBuildBlocks(lines: StyledLine[]): Block[] { return blocks; } + const trust = detectTrustRegion(lines); + if (trust) { + const before = trimTrailingBlank(lines.slice(0, trust.startLine)); + const blocks: Block[] = []; + if (before.length > 0) blocks.push({ kind: "raw", lines: before }); + blocks.push({ kind: "prompt-select", prompt: trust.model, lines: lines.slice(trust.startLine) }); + return blocks; + } + return [{ kind: "raw", lines: stripChrome(lines) }]; } diff --git a/web/src/lib/harness/grok/markers.ts b/web/src/lib/harness/grok/markers.ts index 6dfbd4768..072ffb354 100644 --- a/web/src/lib/harness/grok/markers.ts +++ b/web/src/lib/harness/grok/markers.ts @@ -142,9 +142,12 @@ export function isComposerHint(text: string): boolean { // qualifies: a looser bracket-run pattern would bless torn transcript ending in a bracket tag // (`[waiting]`, `[ERROR]`, …) — reproduced in review — and reopen exactly the hazard the // refusal guards. A new channel chip fails closed until a capture blesses it. -const CHIP_ROW = /^\[stable\]$/; +// +// Grok Build 1.0.52 signed in with an API key prints that fact before the chip, `Logged in with API +// key │ [stable]` (live, 2026-10-15, grok--welcome-card.txt). Only that literal phrase is added. +const CHIP_ROW = /^(?:Logged in with API key\s+│\s+)?\[stable\]$/; -/** True when the row is exactly the startup screen's `[stable]` channel chip. */ +/** True when the row is exactly the startup screen's `[stable]` channel chip, or its API-key form. */ export function isStatusChipRow(text: string): boolean { return CHIP_ROW.test(rstrip(text).trimStart()); } diff --git a/web/src/lib/harness/grok/trust.ts b/web/src/lib/harness/grok/trust.ts new file mode 100644 index 000000000..40c6ac0fb --- /dev/null +++ b/web/src/lib/harness/grok/trust.ts @@ -0,0 +1,82 @@ +// Grok Build's folder-trust prompt, the first screen in an untrusted directory (1.0.52, live +// 2026-10-15). It paints under the logo in place of the composer: +// +// Do you trust the contents of this directory? +// +// Grok Build may run or modify contents in this directory, +// posing security risks. +// +// Yes, proceed y +// No, quit n +// …blank rows… Grok Build 1.0.52 [stable] +// +// Each option prints its own key at its right, and the key alone answers: `y` went on to the +// composer and wrote the folder into `trusted_folders.toml` under GROK_HOME, `n` quit Grok to the +// shell (both measured). No Enter follows, which is the trust family's key-alone recipe. Anything off +// this layout refuses (fail-closed null). Pure; no pane access. + +import type { StyledLine } from "../../blocks"; +import type { PromptModel } from "../prompt-model"; +import { lastNonBlankIndex, lineText, regionSignature, rstrip } from "./markers"; + +export interface TrustRegion { + model: PromptModel; + startLine: number; +} + +const QUESTION_TEXT = "Do you trust the contents of this directory?"; +const QUESTION = /^\s*Do you trust the contents of this directory\?$/; +const YES = /^\s*Yes, proceed\s{2,}y$/; +const NO = /^\s*No, quit\s{2,}n$/; +// Rows between the question and the options: the directory (a long one wraps), the two-row warning +// and the blanks around it. +const QUESTION_SCAN_ROWS = 8; +// Non-blank rows under the options: the version chip at the bottom right. +const STATUS_ROWS = 1; + +/** The trust prompt at the tail, or null. */ +export function detectTrustRegion(lines: StyledLine[]): TrustRegion | null { + const texts = lines.map((l) => rstrip(lineText(l))); + const last = lastNonBlankIndex(texts); + let noRow = -1; + let seen = 0; + for (let i = last; i >= 0; i--) { + if (texts[i]!.trim() === "") continue; + if (NO.test(texts[i]!)) { + noRow = i; + break; + } + if (++seen > STATUS_ROWS) return null; + } + if (noRow < 1 || !YES.test(texts[noRow - 1]!)) return null; + const yesRow = noRow - 1; + + let question = -1; + for (let i = yesRow - 1; i >= 0 && yesRow - 1 - i < QUESTION_SCAN_ROWS; i--) { + if (QUESTION.test(texts[i]!)) { + question = i; + break; + } + } + if (question < 0) return null; + + // From the question through the tail: a different directory, or anything new under the options, + // makes a tap read on the old screen stale. + const signature = regionSignature(lines, question, last + 1); + if (signature === "") return null; + return { + // The card prints the question alone, so it starts at the first option: the directory and the + // warning stay in the mirror above it rather than vanish from both. + startLine: yesRow, + model: { + question: QUESTION_TEXT, + options: [ + { label: "Yes, proceed", keys: ["y"] }, + { label: "No, quit", keys: ["n"] }, + ], + family: "trust", + coreSignature: regionSignature(lines, question, noRow + 1), + signature, + }, + }; +}