@@ -26,10 +26,21 @@ public sealed class GrokWebBrowserResponse
2626 public required string Url { get ; init ; }
2727 }
2828
29+ // Which real grok.com control must initiate the integrity-signed app-chat
30+ // POST. A plain fetch inside Playwright still 403s; only the site's own
31+ // click path attaches a valid x-statsig-id.
32+ public enum GrokWebAppChatTrigger
33+ {
34+ None = 0 ,
35+ Video = 1 ,
36+ ImageEdit = 2 ,
37+ }
38+
2939 // grok.com's app-chat endpoint rejects standalone HTTP clients even when
30- // they copy browser headers. Video generation therefore performs only that
31- // POST inside a real logged-in Chromium page. Uploads, image generation,
32- // media polling, downloads, and saving remain in GrokWebClient.
40+ // they copy browser headers. Video generation and image editing therefore
41+ // perform only that POST inside a real logged-in Chromium page. Uploads,
42+ // image generation (text-to-image WS), media polling, downloads, and
43+ // saving remain in GrokWebClient.
3344 public sealed class GrokWebBrowserClient : IAsyncDisposable , IDisposable
3445 {
3546 public const string ImagineUrl = "https://grok.com/imagine" ;
@@ -67,6 +78,7 @@ public static GrokWebBrowserClientOptions BuildOptions(
6778 public async Task < GrokWebBrowserResponse > PostAppChatAsync (
6879 object payload ,
6980 string ? triggerPostId ,
81+ GrokWebAppChatTrigger trigger = GrokWebAppChatTrigger . None ,
7082 CancellationToken cancellationToken = default )
7183 {
7284 using var timeoutCts = CancellationTokenSource . CreateLinkedTokenSource ( cancellationToken ) ;
@@ -81,11 +93,21 @@ public async Task<GrokWebBrowserResponse> PostAppChatAsync(
8193 await PrepareImaginePageAsync ( triggerPostId , ct ) ;
8294
8395 var payloadJson = JsonSerializer . Serialize ( payload ) ;
84- if ( ! string . IsNullOrWhiteSpace ( triggerPostId ) )
96+ if ( trigger != GrokWebAppChatTrigger . None )
8597 {
86- return await TriggerAppChatFromPageAsync ( payloadJson , ct ) ;
98+ if ( string . IsNullOrWhiteSpace ( triggerPostId ) )
99+ {
100+ throw new GrokWebException (
101+ $ "Grok web browser { trigger } trigger requires a source post id.") ;
102+ }
103+ return trigger == GrokWebAppChatTrigger . ImageEdit
104+ ? await TriggerImageEditAppChatFromPageAsync ( payloadJson , ct )
105+ : await TriggerVideoAppChatFromPageAsync ( payloadJson , ct ) ;
87106 }
88107
108+ // Plain page fetch still 403s on app-chat (no x-statsig-id).
109+ // Callers that need a signed request must pass a Video/ImageEdit
110+ // trigger instead of relying on this path.
89111 var responseTask = _page ! . EvaluateAsync < JsonElement > (
90112 """
91113 async ({ payloadJson, timeoutMs }) => {
@@ -131,7 +153,7 @@ public async Task<GrokWebBrowserResponse> PostAppChatAsync(
131153 catch ( OperationCanceledException ) when ( ! cancellationToken . IsCancellationRequested )
132154 {
133155 throw new GrokWebException (
134- $ "Grok web browser video request timed out after { _options . Timeout . TotalSeconds : 0} seconds.") ;
156+ $ "Grok web browser request timed out after { _options . Timeout . TotalSeconds : 0} seconds.") ;
135157 }
136158 catch ( PlaywrightException ex )
137159 {
@@ -146,7 +168,117 @@ public async Task<GrokWebBrowserResponse> PostAppChatAsync(
146168 }
147169 }
148170
149- private async Task < GrokWebBrowserResponse > TriggerAppChatFromPageAsync (
171+ // Live protocol (2026-07-31): on an uploaded image post, fill the
172+ // composer and click aria-label=Edit. Grok sends modelName
173+ // imagine-image-edit with mediaGenInput.imageToImage.inputAssets.
174+ // Route interception replaces only the body; x-statsig-id stays.
175+ private async Task < GrokWebBrowserResponse > TriggerImageEditAppChatFromPageAsync (
176+ string payloadJson ,
177+ CancellationToken ct )
178+ {
179+ const string endpointPattern = "**/rest/app-chat/conversations/new" ;
180+ var responseSource = new TaskCompletionSource < IResponse > (
181+ TaskCreationOptions . RunContinuationsAsynchronously ) ;
182+
183+ void HandleResponse ( object ? sender , IResponse response )
184+ {
185+ if ( response . Request . Method == "POST"
186+ && response . Url . Contains (
187+ "/rest/app-chat/conversations/new" ,
188+ StringComparison . Ordinal ) )
189+ {
190+ responseSource . TrySetResult ( response ) ;
191+ }
192+ }
193+
194+ Func < IRoute , Task > routeHandler = async route =>
195+ {
196+ await route . ContinueAsync ( new RouteContinueOptions
197+ {
198+ PostData = Encoding . UTF8 . GetBytes ( payloadJson ) ,
199+ } ) ;
200+ } ;
201+
202+ _page ! . Response += HandleResponse ;
203+ await _page . RouteAsync ( endpointPattern , routeHandler ) ;
204+ try
205+ {
206+ var imageToggle = _page . Locator ( "button[aria-label=\" Image\" i]" ) . Last ;
207+ if ( await imageToggle . CountAsync ( ) > 0 && await imageToggle . IsVisibleAsync ( ) )
208+ {
209+ await imageToggle . ClickAsync ( new LocatorClickOptions { Force = true } ) ;
210+ }
211+
212+ var composer = _page . Locator (
213+ "[contenteditable=\" true\" ][aria-label*=\" Ask Grok\" i], "
214+ + "[contenteditable=\" true\" ]" ) . Last ;
215+ await composer . WaitForAsync ( new LocatorWaitForOptions
216+ {
217+ State = WaitForSelectorState . Visible ,
218+ Timeout = 30_000 ,
219+ } ) ;
220+ await composer . ClickAsync ( new LocatorClickOptions { Force = true } ) ;
221+ // Harmless placeholder: the intercepted body carries the real prompt.
222+ await composer . FillAsync ( "edit this image" ) ;
223+
224+ var editButton = _page . Locator ( "button[aria-label=\" Edit\" i]" ) . Last ;
225+ var enabled = false ;
226+ for ( var i = 0 ; i < 40 ; i ++ )
227+ {
228+ ct . ThrowIfCancellationRequested ( ) ;
229+ if ( await editButton . CountAsync ( ) > 0
230+ && await editButton . IsVisibleAsync ( )
231+ && ! await editButton . IsDisabledAsync ( ) )
232+ {
233+ enabled = true ;
234+ break ;
235+ }
236+ await Task . Delay ( 250 , ct ) ;
237+ }
238+ if ( ! enabled )
239+ {
240+ throw new GrokWebException (
241+ "Grok web: the Edit control stayed disabled after filling the composer. "
242+ + "The Imagine post page layout or account controls may have changed; "
243+ + "retry with --grok-web-headed." ) ;
244+ }
245+
246+ await editButton . ClickAsync ( new LocatorClickOptions { Force = true } ) ;
247+
248+ IResponse response ;
249+ try
250+ {
251+ response = await responseSource . Task . WaitAsync ( TimeSpan . FromSeconds ( 30 ) , ct ) ;
252+ }
253+ catch ( TimeoutException )
254+ {
255+ var screenshotPath = Path . Combine (
256+ Path . GetTempPath ( ) ,
257+ $ "grok-web-edit-{ DateTime . Now : yyyyMMdd-HHmmss} .png") ;
258+ await _page . ScreenshotAsync ( new PageScreenshotOptions
259+ {
260+ Path = screenshotPath ,
261+ FullPage = true ,
262+ } ) ;
263+ Logger . Log ( $ "Grok web edit trigger failed; screenshot: { screenshotPath } ") ;
264+ throw new GrokWebException (
265+ "Grok web: the real Edit control did not start an app-chat request. "
266+ + "The Imagine page layout or account controls may have changed; retry with --grok-web-headed." ) ;
267+ }
268+
269+ // Edit results arrive in the streaming body as relative
270+ // generatedImageUrls / streamingImageGenerationResponse URLs.
271+ // Wait long enough to collect finals; do not rely on liked-post polling.
272+ return await ReadAppChatResponseAsync ( response , ct , bodyTimeoutSeconds : 90 ) ;
273+ }
274+ finally
275+ {
276+ _page . Response -= HandleResponse ;
277+ await _page . UnrouteAsync ( endpointPattern , routeHandler ) ;
278+ }
279+ }
280+
281+ private async Task < GrokWebBrowserResponse > TriggerVideoAppChatFromPageAsync (
150282 string payloadJson ,
151283 CancellationToken ct )
152284 {
@@ -311,38 +443,7 @@ await _page.ScreenshotAsync(new PageScreenshotOptions
311443 "Grok web: the real Make Video control did not start an app-chat request. "
312444 + "The Imagine page layout or account controls may have changed; retry with --grok-web-headed." ) ;
313445 }
314- string body ;
315- try
316- {
317- // The endpoint streams. On silent moderation failures Grok can
318- // leave the body open indefinitely after returning HTTP 200.
319- // The durable media-post poll is the source of truth, so do
320- // not hold the whole video job for the 15-minute browser limit.
321- var bodyTimeout = TimeSpan . FromSeconds (
322- Math . Min ( 30 , Math . Max ( 5 , _options . Timeout . TotalSeconds ) ) ) ;
323- body = await response . TextAsync ( ) . WaitAsync ( bodyTimeout , ct ) ;
324- }
325- catch ( TimeoutException )
326- {
327- body = "" ;
328- Logger . Log (
329- "Grok web browser: app-chat returned headers but left its streaming body open; "
330- + "continuing with media-post polling." ) ;
331- }
332- catch ( PlaywrightException ex )
333- {
334- body = "" ;
335- Logger . Log (
336- $ "Grok web browser: could not read the accepted app-chat stream ({ ex . Message } ); "
337- + "continuing with media-post polling." ) ;
338- }
339-
340- return new GrokWebBrowserResponse
341- {
342- StatusCode = response . Status ,
343- Body = body ,
344- Url = response . Url ,
345- } ;
446+ return await ReadAppChatResponseAsync ( response , ct , bodyTimeoutSeconds : 30 ) ;
346447 }
347448 finally
348449 {
@@ -351,6 +452,45 @@ await _page.ScreenshotAsync(new PageScreenshotOptions
351452 }
352453 }
353454
455+ private async Task < GrokWebBrowserResponse > ReadAppChatResponseAsync (
456+ IResponse response ,
457+ CancellationToken ct ,
458+ int bodyTimeoutSeconds )
459+ {
460+ string body ;
461+ try
462+ {
463+ // The endpoint streams. On silent moderation failures Grok can
464+ // leave the body open indefinitely after returning HTTP 200.
465+ // Video can fall back to media-post polling; image edit needs the
466+ // streamed finals, so callers pass a longer bodyTimeoutSeconds.
467+ var bodyTimeout = TimeSpan . FromSeconds (
468+ Math . Min ( bodyTimeoutSeconds , Math . Max ( 5 , _options . Timeout . TotalSeconds ) ) ) ;
469+ body = await response . TextAsync ( ) . WaitAsync ( bodyTimeout , ct ) ;
470+ }
471+ catch ( TimeoutException )
472+ {
473+ body = "" ;
474+ Logger . Log (
475+ "Grok web browser: app-chat returned headers but left its streaming body open; "
476+ + "continuing with whatever partial body was already collected / media-post polling." ) ;
477+ }
478+ catch ( PlaywrightException ex )
479+ {
480+ body = "" ;
481+ Logger . Log (
482+ $ "Grok web browser: could not read the accepted app-chat stream ({ ex . Message } ); "
483+ + "continuing with media-post polling." ) ;
484+ }
485+
486+ return new GrokWebBrowserResponse
487+ {
488+ StatusCode = response . Status ,
489+ Body = body ,
490+ Url = response . Url ,
491+ } ;
492+ }
493+
354494 private async Task EnsureStartedAsync ( CancellationToken ct )
355495 {
356496 if ( _context != null )
0 commit comments