Skip to content

Commit 3b585fb

Browse files
authored
feat(grok): add browser chat baseline commands (read/history/detail/new/send/status) (#1392)
Phase 3 — Grok adapter baseline (LLM browser-chat command family, parallel to ChatGPT/Qwen/Yuanbao). ## Surface 6 commands: `status` / `history` / `read` / `detail` / `new` / `send`. Site-local `clis/grok/utils.js` justified by 6 commands sharing helpers, not over-abstraction. ## 4-head review iteration 1. **`b4e81bad`** — initial baseline (12 Grok/shared files) 2. **`0a8112fc`** — mechanical rebase (CHANGELOG conflict only, all 12 Grok files preserved business-equivalent through rebase) 3. **`481e87e2`** — security fix: `parseGrokSessionId()` SSRF-shape vulnerability close — switched from regex string match to `new URL()` parser with branch separation: - Bare UUID mode: only exact UUID shape (no URL/query suffix accepted) - URL mode: requires `https` scheme + exact `grok.com` or subdomain host + exact `/c/<uuid>` path 4. **`a082023c`** — test-only hardening: 2 additional negative anchors covering existing implementation rejections (bare UUID `?next=abc` query tail / `grok.com.evil.com` host-suffix trick) ## Negative anchor coverage (8 cases) http / off-domain / fakegrok / host-suffix subdomain / embedded URL / path suffix / UUID-tail / bare query tail ## Better-solution evidence form LLM browser-chat family pattern (matching ChatGPT/Qwen/Yuanbao baseline) + 5 live probes — not first-site hostile scrape. TipTap editor API send seam (`editor.commands.focus/clearContent/insertContent`) is correct boundary because Grok ignores DOM input events; isolated in `sendMessage()`. Lack of full TipTap mock = residual risk, not blocker. ## Invariants locked - `parseGrokSessionId()` URL parser branch separation (bare UUID exact / URL exact path) - `history --limit` rejects invalid/out-of-range - `status` uses `null` for unknowns (no fabrication) - Bubble extraction preserves image-only assistant turns (no silent HTML-only drop) - Auth/empty semantics aligned with LLM browser-chat baseline family ## Verification Local: Grok adapter tests `28/28`, typecheck, build/manifest, docs:build, typed-error-lint `189/189`, silent-column-drop `103/103`, doc coverage `140/140`, listing-id advisory `13` unchanged, diff-check clean. GitHub: build ubuntu/macos/windows × unit-test 1/2 + 2/2, bun-test, adapter-test, audit, doc-coverage, docs-build all SUCCESS. PR CLEAN/MERGEABLE. Lead: codex-mini1. Aux: First-principles-1. Coordination: pr-monitor.
1 parent 9cae777 commit 3b585fb

12 files changed

Lines changed: 924 additions & 39 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
* **browser lifecycle** — owned browser workspaces now lease tabs inside a shared dedicated automation container instead of owning one Chrome window per workspace; lease state is persisted for MV3 service-worker reconciliation and idle cleanup is backed by alarms.
1515
* **browser session** — adapter commands can opt into site-level tab reuse with `browserSession.reuse = 'site'`; Grok and other browser-backed LLM adapters now keep a shared site tab by default, and users can override with `--reuse <none|site>`.
1616
* **chatgpt** — add browser-web baseline commands: `ask`, `send`, `read`, `history`, `detail`, `new`, and `status`.
17+
* **grok** — add browser-web baseline commands: `read`, `history`, `detail`, `new`, `send`, and `status` (existing `ask` and `image` unchanged).
1718
* **yuanbao** — add browser-web baseline commands: `send`, `status`, `read`, `history`, and `detail` (joining the existing `ask` and `new`).
1819
* **web read** — make page extraction render-aware: same-origin iframe content is merged into the Markdown source, `--wait-for` can wait inside main/iframe documents, `--wait-until networkidle` waits for captured requests to settle, and `--diagnose` reports frames, empty containers, and API-like XHRs for shell/AJAX pages.
1920

cli-manifest.json

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10281,6 +10281,181 @@
1028110281
"reuse": "site"
1028210282
}
1028310283
},
10284+
{
10285+
"site": "grok",
10286+
"name": "detail",
10287+
"description": "Open a Grok conversation by ID and read its messages",
10288+
"access": "read",
10289+
"domain": "grok.com",
10290+
"strategy": "cookie",
10291+
"browser": true,
10292+
"args": [
10293+
{
10294+
"name": "id",
10295+
"type": "str",
10296+
"required": true,
10297+
"positional": true,
10298+
"help": "Session ID (UUID) or full https://grok.com/c/<id> URL"
10299+
},
10300+
{
10301+
"name": "markdown",
10302+
"type": "boolean",
10303+
"default": false,
10304+
"required": false,
10305+
"help": "Emit assistant replies as markdown"
10306+
}
10307+
],
10308+
"columns": [
10309+
"Role",
10310+
"Text"
10311+
],
10312+
"type": "js",
10313+
"modulePath": "grok/detail.js",
10314+
"sourceFile": "grok/detail.js",
10315+
"navigateBefore": false,
10316+
"browserSession": {
10317+
"reuse": "site"
10318+
}
10319+
},
10320+
{
10321+
"site": "grok",
10322+
"name": "history",
10323+
"description": "List recent Grok conversations from the sidebar (requires login)",
10324+
"access": "read",
10325+
"domain": "grok.com",
10326+
"strategy": "cookie",
10327+
"browser": true,
10328+
"args": [
10329+
{
10330+
"name": "limit",
10331+
"type": "int",
10332+
"default": 20,
10333+
"required": false,
10334+
"help": "Max conversations to show (default 20, max 100)"
10335+
}
10336+
],
10337+
"columns": [
10338+
"Index",
10339+
"Title",
10340+
"Url"
10341+
],
10342+
"type": "js",
10343+
"modulePath": "grok/history.js",
10344+
"sourceFile": "grok/history.js",
10345+
"navigateBefore": false,
10346+
"browserSession": {
10347+
"reuse": "site"
10348+
}
10349+
},
10350+
{
10351+
"site": "grok",
10352+
"name": "new",
10353+
"description": "Start a new conversation in Grok",
10354+
"access": "write",
10355+
"domain": "grok.com",
10356+
"strategy": "cookie",
10357+
"browser": true,
10358+
"args": [],
10359+
"columns": [
10360+
"Status"
10361+
],
10362+
"type": "js",
10363+
"modulePath": "grok/new.js",
10364+
"sourceFile": "grok/new.js",
10365+
"navigateBefore": false,
10366+
"browserSession": {
10367+
"reuse": "site"
10368+
}
10369+
},
10370+
{
10371+
"site": "grok",
10372+
"name": "read",
10373+
"description": "Read messages in the current Grok conversation",
10374+
"access": "read",
10375+
"domain": "grok.com",
10376+
"strategy": "cookie",
10377+
"browser": true,
10378+
"args": [
10379+
{
10380+
"name": "markdown",
10381+
"type": "boolean",
10382+
"default": false,
10383+
"required": false,
10384+
"help": "Emit assistant replies as markdown"
10385+
}
10386+
],
10387+
"columns": [
10388+
"Role",
10389+
"Text"
10390+
],
10391+
"type": "js",
10392+
"modulePath": "grok/read.js",
10393+
"sourceFile": "grok/read.js",
10394+
"navigateBefore": false,
10395+
"browserSession": {
10396+
"reuse": "site"
10397+
}
10398+
},
10399+
{
10400+
"site": "grok",
10401+
"name": "send",
10402+
"description": "Fire-and-forget: send a prompt to Grok without waiting for the reply",
10403+
"access": "write",
10404+
"domain": "grok.com",
10405+
"strategy": "cookie",
10406+
"browser": true,
10407+
"args": [
10408+
{
10409+
"name": "prompt",
10410+
"type": "str",
10411+
"required": true,
10412+
"positional": true,
10413+
"help": "Prompt to send to Grok"
10414+
},
10415+
{
10416+
"name": "new",
10417+
"type": "boolean",
10418+
"default": false,
10419+
"required": false,
10420+
"help": "Start a new chat before sending"
10421+
}
10422+
],
10423+
"columns": [
10424+
"Status",
10425+
"Prompt"
10426+
],
10427+
"type": "js",
10428+
"modulePath": "grok/send.js",
10429+
"sourceFile": "grok/send.js",
10430+
"navigateBefore": false,
10431+
"browserSession": {
10432+
"reuse": "site"
10433+
}
10434+
},
10435+
{
10436+
"site": "grok",
10437+
"name": "status",
10438+
"description": "Check Grok page availability, login state, current session and model",
10439+
"access": "read",
10440+
"domain": "grok.com",
10441+
"strategy": "cookie",
10442+
"browser": true,
10443+
"args": [],
10444+
"columns": [
10445+
"Status",
10446+
"Login",
10447+
"Model",
10448+
"SessionId",
10449+
"Url"
10450+
],
10451+
"type": "js",
10452+
"modulePath": "grok/status.js",
10453+
"sourceFile": "grok/status.js",
10454+
"navigateBefore": false,
10455+
"browserSession": {
10456+
"reuse": "site"
10457+
}
10458+
},
1028410459
{
1028510460
"site": "hackernews",
1028610461
"name": "ask",

clis/grok/detail.js

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { cli, Strategy } from '@jackwener/opencli/registry';
2+
import { EmptyResultError } from '@jackwener/opencli/errors';
3+
import {
4+
GROK_DOMAIN,
5+
bubbleHtmlToMarkdown,
6+
getMessageBubbles,
7+
normalizeBooleanFlag,
8+
parseGrokSessionId,
9+
} from './utils.js';
10+
11+
cli({
12+
site: 'grok',
13+
name: 'detail',
14+
access: 'read',
15+
description: 'Open a Grok conversation by ID and read its messages',
16+
domain: GROK_DOMAIN,
17+
strategy: Strategy.COOKIE,
18+
browser: true,
19+
navigateBefore: false,
20+
browserSession: { reuse: 'site' },
21+
args: [
22+
{ name: 'id', positional: true, required: true, help: 'Session ID (UUID) or full https://grok.com/c/<id> URL' },
23+
{ name: 'markdown', type: 'boolean', default: false, help: 'Emit assistant replies as markdown' },
24+
],
25+
columns: ['Role', 'Text'],
26+
func: async (page, kwargs) => {
27+
const sessionId = parseGrokSessionId(kwargs.id);
28+
const wantMarkdown = normalizeBooleanFlag(kwargs.markdown, false);
29+
30+
await page.goto(`https://grok.com/c/${sessionId}`);
31+
await page.wait(2);
32+
33+
// Poll for the conversation transcript to load. Grok renders the chat
34+
// page shell before fetching message history, so a fixed wait can race
35+
// the initial empty render. Cap at ~20s so genuinely missing IDs surface
36+
// an EmptyResultError rather than hanging.
37+
let bubbles = [];
38+
const POLL_DEADLINE_MS = 20_000;
39+
const POLL_INTERVAL_S = 1;
40+
const startedAt = Date.now();
41+
while (Date.now() - startedAt < POLL_DEADLINE_MS) {
42+
bubbles = await getMessageBubbles(page);
43+
if (bubbles.length > 0) break;
44+
await page.wait(POLL_INTERVAL_S);
45+
}
46+
47+
if (!bubbles.length) {
48+
throw new EmptyResultError(
49+
'grok detail',
50+
`No visible messages found for conversation ${sessionId}. Verify the ID is correct and that the conversation belongs to the signed-in account.`,
51+
);
52+
}
53+
return bubbles.map((b) => ({
54+
Role: b.role,
55+
Text: wantMarkdown && b.role === 'Assistant' && b.html
56+
? (bubbleHtmlToMarkdown(b.html) || b.text)
57+
: b.text,
58+
}));
59+
},
60+
});

clis/grok/history.js

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { cli, Strategy } from '@jackwener/opencli/registry';
2+
import { ArgumentError, EmptyResultError } from '@jackwener/opencli/errors';
3+
import {
4+
GROK_DOMAIN,
5+
authRequired,
6+
ensureOnGrok,
7+
getHistoryFromSidebar,
8+
isLoggedIn,
9+
} from './utils.js';
10+
11+
cli({
12+
site: 'grok',
13+
name: 'history',
14+
access: 'read',
15+
description: 'List recent Grok conversations from the sidebar (requires login)',
16+
domain: GROK_DOMAIN,
17+
strategy: Strategy.COOKIE,
18+
browser: true,
19+
browserSession: { reuse: 'site' },
20+
navigateBefore: false,
21+
args: [
22+
{ name: 'limit', type: 'int', default: 20, help: 'Max conversations to show (default 20, max 100)' },
23+
],
24+
columns: ['Index', 'Title', 'Url'],
25+
func: async (page, kwargs) => {
26+
const limit = Number(kwargs.limit ?? 20);
27+
if (!Number.isInteger(limit) || limit <= 0) {
28+
throw new ArgumentError('limit', 'must be a positive integer');
29+
}
30+
if (limit > 100) {
31+
throw new ArgumentError('limit', 'must be <= 100');
32+
}
33+
await ensureOnGrok(page);
34+
await page.wait(2);
35+
if (!(await isLoggedIn(page))) {
36+
throw authRequired();
37+
}
38+
const sessions = await getHistoryFromSidebar(page, limit);
39+
if (!sessions.length) {
40+
throw new EmptyResultError('grok history', 'No Grok conversations found in the sidebar for the signed-in account.');
41+
}
42+
return sessions.slice(0, limit).map((s, i) => ({
43+
Index: i + 1,
44+
Title: s.title || '(untitled)',
45+
Url: `https://grok.com/c/${s.id}`,
46+
}));
47+
},
48+
});

clis/grok/new.js

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { cli, Strategy } from '@jackwener/opencli/registry';
2+
import { GROK_DOMAIN, startNewChat } from './utils.js';
3+
4+
cli({
5+
site: 'grok',
6+
name: 'new',
7+
access: 'write',
8+
description: 'Start a new conversation in Grok',
9+
domain: GROK_DOMAIN,
10+
strategy: Strategy.COOKIE,
11+
browser: true,
12+
browserSession: { reuse: 'site' },
13+
navigateBefore: false,
14+
args: [],
15+
columns: ['Status'],
16+
func: async (page) => {
17+
await startNewChat(page);
18+
return [{ Status: 'New chat started' }];
19+
},
20+
});

clis/grok/read.js

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { cli, Strategy } from '@jackwener/opencli/registry';
2+
import {
3+
GROK_DOMAIN,
4+
bubbleHtmlToMarkdown,
5+
ensureOnGrok,
6+
getMessageBubbles,
7+
normalizeBooleanFlag,
8+
} from './utils.js';
9+
10+
cli({
11+
site: 'grok',
12+
name: 'read',
13+
access: 'read',
14+
description: 'Read messages in the current Grok conversation',
15+
domain: GROK_DOMAIN,
16+
strategy: Strategy.COOKIE,
17+
browser: true,
18+
browserSession: { reuse: 'site' },
19+
navigateBefore: false,
20+
args: [
21+
{ name: 'markdown', type: 'boolean', default: false, help: 'Emit assistant replies as markdown' },
22+
],
23+
columns: ['Role', 'Text'],
24+
func: async (page, kwargs) => {
25+
const wantMarkdown = normalizeBooleanFlag(kwargs.markdown, false);
26+
await ensureOnGrok(page);
27+
await page.wait(2);
28+
const bubbles = await getMessageBubbles(page);
29+
if (!bubbles.length) {
30+
return [{ Role: 'system', Text: 'No visible messages in the current conversation.' }];
31+
}
32+
return bubbles.map((b) => ({
33+
Role: b.role,
34+
Text: wantMarkdown && b.role === 'Assistant' && b.html
35+
? (bubbleHtmlToMarkdown(b.html) || b.text)
36+
: b.text,
37+
}));
38+
},
39+
});

0 commit comments

Comments
 (0)