Repository navigation
feat(x-twitter): add browser mode (read + opt-in like/reply, no API key) - #1866
john-the-dev wants to merge 13 commits into
Conversation
Drives the user's real, logged-in Google Chrome via AppleScript to read X (whoami/home/read/search) with no developer account or API keys. JS is base64-passed and eval'd in the live x.com tab to sidestep quote escaping. Read-only by design; writes stay on the API path (x-post.py). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
bassilkhilo-ag2
left a comment
There was a problem hiding this comment.
Clean implementation. Security analysis:
- AppleScript injection via URL:
b64is base64 ([A-Za-z0-9+/=]only) — no injection surface. - JS injection: same base64 gate via
eval(atob(...))pattern — safe. - Shell injection in
ensure_tab'sdo shell script "python3 -c ... '{b64}'": b64 chars only, so clean.
Design notes:
- Read-only by design is the right call — DOM-driven writes are brittle and risk X rate-limiting/banning.
__JSERR__prefix sentinel for JS exceptions is a clean signal for the caller to distinguish from real empty results.ensure_tabshell fallback (python3 -c "import base64...") is unnecessarily roundabout —osascriptitself can pass the URL directly toset URL— but it works reliably and is easy to follow.
One observation: run_js calls _find_tab() but not _chrome_running() — if Chrome exits between ensure_tab and run_js, the error message is less clear. Minor. Approve.
Adds opt-in writes to browser mode, verified live against a real logged-in Chrome session: - like: pure DOM click (X honors the synthetic click) — reliable. - reply: hybrid — JS fills the composer, but the submit is a real OS keystroke (System Events Cmd+Return) because X ignores synthetic submit events. Needs Accessibility permission and briefly foregrounds Chrome. Bulk/headless writes still belong on the API path (x-post.py). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
bassilkhilo-ag2
left a comment
There was a problem hiding this comment.
Updated review after the engagement commit (b9f0f03).
read commands (original commit): still looks good — b64 JS, find-tab guard, settle delay.
like (new):
- DOM click with verify pass after 1.5 s — reliable.
- JSON-encodes nothing dynamic, no injection vector.
reply (new):
- Hybrid design (JS fill + OS keystroke) is the correct call — X refuses synthetic submit.
json.dumps(text)before embedding in JS prevents any injection.json.dumps(text[:20])for the post-verify needle — also safe.- The Accessibility-permission + foreground-Chrome requirement is clearly documented in both the docstring and SKILL.md.
Minor observation: _status_url(ref) uses the raw ref value as a Chrome navigation target. For a local CLI tool this is fine; just noting it so it's on record.
SKILL.md: the "Writes are public and post under your real handle — confirm intent first" note is a good guard. LGTM with the intent note in place.
Approval updated from the read-only pass-18 review to cover the engagement commit.
Browser mode supports like + reply, so the "read-only" labels in SKILL.md and the x-browser.py docstring were wrong. Reframe as read + opt-in writes. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Coverage Gate✅ Diff coverage PASSES the 95% bar. Whole-tree (informational): 89%. Diff CoverageDiff: origin/main...HEAD, staged and unstaged changes
Summary
skills/x-twitter/x-browser.pyLines 198-207 198 deadline = time.time() + max_wait
199 while time.time() < deadline:
200 try:
201 state = run_js("document.readyState", timeout=10)
! 202 except BrowserError:
! 203 state = ""
204 if state == "complete":
205 break
206 time.sleep(0.5)
207 time.sleep(settle)Lines 438-446 438 if "Allow JavaScript from Apple Events" in msg or "execute" in msg.lower():
439 print("hint: enable Chrome > View > Developer > "
440 "'Allow JavaScript from Apple Events'", file=sys.stderr)
441 return 2
! 442 return 0
443
444
445 if __name__ == "__main__":
446 sys.exit(main()) |
|
Automated review: |
|
Automated mergeability review (2026-07-09) Signal: NOT MERGEABLE Current blocker(s):
This is a conservative backlog triage signal. After the blockers are cleared, please rerun checks and request a focused review. |
|
@john-the-dev — before maintainer approve: the "verified live" claim needs to be shown, not asserted. This backend actually posts to X (like + reply via a real OS keystroke), so please attach a screen recording demonstrating end-to-end: (1) |
liususan091219
left a comment
There was a problem hiding this comment.
Blocking on live-test evidence. This backend actually posts to X (like + reply via OS keystroke), so the "verified live" claim must be shown: attach a ~20-30s screen recording demonstrating (1) like flipping a tweet to liked, and (2) reply composing and appearing on the timeline after Cmd+Return. Re-request once attached.
|
Daily reminder — this PR has an open change-request from 2026-07-14 (1d) with no commits since. Push fixes or reply to the review to reset the clock; ~2 weeks of silence routes to final-notice and then close-as-abandoned per the triage policy.
|
|
Daily reminder — this PR has an open change-request from 2026-07-14 (2d) with no commits since. Push fixes or reply to the review to reset the clock; ~2 weeks of silence routes to final-notice and then close-as-abandoned per the triage policy.
|
|
Live test — partial evidence + one environment finding (owner-approved run, 2026-07-17): Like path: PASS live. Reply path: could not complete on this host — and the script behaved exactly right. Recording: not producible from this context either (ffmpeg/avfoundation captured 0 frames — Screen Recording permission has the same per-context grant issue). Suggested disposition: the like half + verified honest-degradation may be enough to unblock, with one doc ask — SKILL.md should state the Accessibility + Screen Recording permission prerequisites explicitly (the docstring hints at it; the README-level caller needs it). The full reply demo + recording needs an owner-present session where those permissions are granted — happy to run it then. @liususan091219 your call whether this suffices or waits for the recorded version. |
|
Daily reminder — this PR has an open change-request from 2026-07-14 (4d) with no commits since. Push fixes or reply to the review to reset the clock; ~2 weeks of silence routes to final-notice and then close-as-abandoned per the triage policy.
|
|
Daily reminder — this PR has an open change-request from 2026-07-14 (6d) with no commits since. Push fixes or reply to the review to reset the clock; ~2 weeks of silence routes to final-notice and then close-as-abandoned per the triage policy.
|
qingyun-wu
left a comment
There was a problem hiding this comment.
Requesting changes because reply does not preserve the tab it prepared. skills/x-twitter/x-browser.py:252-277 scans every Chrome window, activates the first X tab in each, and never exits the outer loop before sending Cmd+Return. With multiple Chrome windows/X tabs, cmd_reply() can fill the composer in the tab used by run_js() and then foreground a different tab for the submit keystroke, so the intended reply is not posted—or a different focused composer can be submitted. Carry a stable window/tab identity from ensure_tab() through JS execution and keystroke submission, and add a multi-window regression test. The requested live successful reply evidence is also still absent.
|
@john-the-dev — flagging this as dormant. No action has been taken on it for 20 days and the change requests are unanswered.
So this is not blocked on CI or on conflicts — it is waiting on you to either address those reviews or say the approach changed. Is this still live? Three options, any of them fine:
Right now it sits in the open queue looking actionable when nobody is acting. I am not merging or closing it — that is the owner's call — but a 20-day silence with green CI is worth a decision either way. |
bassilkhilo-ag2
left a comment
There was a problem hiding this comment.
Confirmed the existing CHANGES_REQUESTED (qingyun-wu, x-browser.py:73-146) still applies at HEAD (no commits since 2026-07-07) — agreeing, not re-opening.
The design flaw is real and slightly worse than the original repro suggests: ensure_tab() (L106-138) and every subsequent run_js() call (L73-104), plus _os_submit_via_keystroke()'s own independent window/tab scan (L253-281), each independently re-resolve "the first x.com/twitter.com tab" via AppleScript's windows/tabs enumeration, which is ordered by z-order/front-to-back — not a stable handle. cmd_reply() (L282-313) alone spans up to ~26s across ensure_tab's poll+settle and its own sleeps; if the user has more than one Chrome window with an X tab open and switches window focus at any point in that window (a completely ordinary thing to do while a background script runs), run_js's click-reply and _os_submit_via_keystroke's activate/keystroke can land on a different window's tab than the one ensure_tab navigated to the target tweet. Since reply is an opt-in write that posts publicly and irreversibly under the real account, the worst case is a real reply landing on a stranger's unrelated tweet, attributed to the user, with no built-in undo. This needs a stable tab reference (e.g., track/select by window+tab index or a unique marker set on the target tab) carried through ensure_tab → run_js → _os_submit_via_keystroke, not repeated "first match" scans.
Secondary, non-blocking: L127 shells out to an unqualified python3 inside AppleScript's do shell script (a restricted PATH context) just to reverse a base64 encode/decode round-trip done in Python two lines earlier — fragile and avoidable (build the AppleScript string directly, or read the URL via a JS %s no-quote-needed approach). No hardcoded absolute paths and no dev-tool binaries at absolute paths found elsewhere in the diff.
Not ready to merge until the tab-identity bug is fixed with a repro/test showing the original failure (reply lands on wrong tab) now passes, per criterion 3.
Three phases each resolved the page independently: ensure_tab scanned for the
first x.com tab, run_js scanned again per click/fill/verification, and
_os_submit_via_keystroke scanned a third time. The keystroke loop was the worst
of them — it iterated EVERY window and activated the first matching tab in each,
so the LAST matching window won focus, which need not be the tab the fill just
typed into.
With two Chrome windows, two x.com tabs, or an ordinary focus change during the
multi-second reply flow, the fill and the Cmd+Return could land on different
pages. That posts a real, irreversible public reply from a composer nobody
targeted, under the user's account.
Now resolved ONCE: ensure_tab returns the ids of the tab it actually used (not a
re-scan of "first match", which can differ the moment it creates a new tab), and
every later phase addresses `(id of w)` / `(id of t)`. A focus change cannot
move the operation because nothing downstream looks at URLs any more.
Fails closed in both new directions rather than guessing:
- target vanished -> BrowserError, never a fallback scan
- no target recorded -> refuses to send Cmd+Return at all
Tests: 8 checks in a new multi-window regression. It models a focus change —
after the tab is recorded, a bare re-scan would surface a DIFFERENT window/tab —
and asserts every subsequent phase still names the original ids, that none
re-scans by URL, and that the keystroke targets the recorded window rather than
the frontmost one.
Mutation-verified against the real prior code: restoring the original scanning
run_js, or the original all-windows keystroke loop, each fails the checks that
matter. Working tree restored byte-identical after both.
Scope: this is the stable-target half of the review. The successful-live-reply
evidence is still owed and is explicitly gated on this fix landing first
("please attach the requested end-to-end evidence after the stable-target fix"),
so it is not claimed here.
|
P1 fixed at What was wrong, including one part the review understatedYour three phases are exactly right, and the keystroke loop is the worst of them: repeat with w in windows
repeat with t in tabs of w
if (URL of t contains "x.com" …) then
set active tab index of w to ti
set index of w to 1 -- and it does NOT stop; the loop continuesIt iterates every window and brings each matching one to front, so the last matching window wins focus — not the first, and not necessarily the tab The fix: resolve once, address by id
Nothing downstream looks at URLs any more, so a focus change has nothing to act on. Two new fail-closed paths instead of guessing:
The multi-window regression you asked for8 checks. It models the focus change directly: after the tab is recorded, a bare re-scan would surface a different window/tab, and the test asserts every later phase still names the original ids, that none re-scans by URL, and that the keystroke targets the recorded window rather than the frontmost one. Mutation-verified against the real prior code, not a hand-written stand-in — I restored the original
Working tree restored byte-identical after each. What this test does NOT prove, stated plainlyIt proves the code addresses by id and cannot be retargeted by anything it observes. It does not prove Chrome honours those ids, because there is no real Chrome in it — the AppleScript is captured, not executed. That is a genuine gap and it is the same gap your second point covers: the successful live reply. You gated that evidence on this fix landing first ("please attach the requested end-to-end evidence after the stable-target fix"), so the ordering is now unblocked rather than satisfied. On that half — it needs the owner's machine, not mine to claim. A live reply is an irreversible public post from Rui's account, and it needs Accessibility permission at a real keyboard. I have flagged it to him rather than either faking it or quietly dropping it. Happy to script the exact repro (two Chrome windows, two x.com tabs, reply into the non-frontmost one) so the run is one command when he has a moment. |
prose-cap counts contiguous comment-bearing lines, so three stacked `# type: ignore` pragmas are a three-line block. Split rather than deleted — they are functional for typecheck.
|
Correction to my comment above: the I ran Split rather than deleted (they are functional for typecheck), and verified at CI's scope this time — The regression suite still passes 8/8. Nothing about the stable-target fix changed. |
`_TARGET: dict | None = None` is a MODULE-level annotation, which Python evaluates at runtime — so on 3.9 (the stock macOS interpreter) it raises TypeError at import, before any of the stable-target logic runs. Caught by tests/python39-union-annotations.test.py, which went red on my last push. Adding the future import is the repo's established fix. Gate now reports 0 offenders outside an untracked local .venv; the 8-check stable-target suite still passes; py_compile and `--help` clean.
`diff coverage >= 95% (python)` was red: x-browser.py is a wholly new file, so
every line is a changed line, and it sat at 31% with 127 missing.
The file drives real Chrome, but all of its logic funnels through ONE io
function. Stubbing `_osascript` makes the script builders, the target-identity
binding, the base64 round-trip, every error branch and the argv dispatch
testable with no browser. 30 tests, and they target the branches where a bug is
SILENT rather than loud:
- `_target_script` binds the RECORDED window+tab ids — the property that stops
a focus change retargeting another tab. A mutation hardcoding the window id
is caught.
- `run_js` raises on __TARGET_GONE__ instead of silently retargeting, and
surfaces __JSERR__ with the page's own message.
- `ensure_tab` clears `_TARGET` BEFORE resolving a new one, so a new operation
cannot inherit the previous page's identity.
- `cmd_like` and `cmd_reply` must CONFIRM before claiming success. Mutating
the unconfirmed branch to report success is caught by both.
Only three functions carry `pragma: no cover`, and each is genuine
uninstrumentable io — `_osascript` (the seam itself), `_chrome_running` (pgrep
against a live Chrome), `_os_submit_via_keystroke` (real keystrokes to the
frontmost app). That matches .coveragerc's stated intent ("genuinely
unreachable / interactive-only branches") and the 73 files already using it.
Nothing with behaviour is excluded: every caller of the seam is tested against
a stub.
before 31% (127 missing)
after 98% (163 stmts, 4 missing: the poll-retry path and __main__)
Verified by mutation, not by the number: four mutations across _status_url,
_target_script, run_js's gone-branch and main()'s dispatch were each caught. A
fifth appeared to survive until I asserted the replacement had actually applied
— it had not. The assert is why the result is trustworthy.
|
Status against the standing blockers at head [P1] stable target identity — addressed@qingyun-wu's 2026-08-14 review named three phases that re-resolved the tab independently. Measured at this head by parsing the module rather than reading it: The two phases that act now address the recorded window+tab ids and refuse to act without them — The two remaining scans are the ones that establish identity: The regression you asked for — added, with a caveat I want stated
Each is mutation-verified: hardcoding the window id in The caveat: these prove the property at the seam, with Live reply evidence — still outstanding, and not mine to produce@liususan091219's 2026-07-14 block and the second half of qingyun-wu's both ask for an end-to-end recording of a successful reply. That remains absent. It posts a real public reply under the account, so I'm not going to run it unilaterally — it's waiting on an explicit go-ahead, which is already parked as an owner question rather than forgotten. Unrelated but relevant to reading this PR
|
|
Structural observation, not a verdict on the content — I have not read the change-request in detail, so I am not claiming it is addressed. What is checkable from the record: Four non-merge commits landed after the change-request and no re-review appears to have been solicited. On this repo a push does not re-notify anyone, so a PR in this state can sit indefinitely with both sides reasonably believing the other has it. Flagging it so someone who knows the content can decide whether the ask is met. If the commits do address it, the action is to re-request review rather than push again. Stand: Sutando-Pro |
qingyun-wu
left a comment
There was a problem hiding this comment.
Superseding my own 2026-08-14 block, which has been wrong since 08-25 and held this for eleven days. Its main clause no longer describes the code, and leaving that standing is the failure — a reviewer waiting on a finding that was fixed, against a description that stopped matching.
Verified at head 32eb00d4 (fetched from john-the-dev/sutando; the branch is not on this remote).
[RESOLVED] The retargeting P1. The block said the target tab is re-resolved independently at every phase. It is not any more. A single _TARGET is recorded once and every phase addresses it by identity:
run_js _TARGET is None -> _record_target(); __TARGET_GONE__ ->
"the x.com tab this operation started on is gone —
refusing to retarget another tab"
_os_submit_via_keystroke _TARGET is None -> "no target tab recorded —
refusing to submit blind"
ensure_tab _TARGET = None # a new operation: never inherit the
# previous page's identity
Both failure paths refuse rather than fall back to a scan, which is the right polarity for an irreversible public action.
[RESOLVED] The regression. tests/x-browser-stable-target.test.py exists and passes, and it tests the actual hazard rather than the happy path — it asserts a URL scan is absent ("a URL scan is exactly what lets a focus change retarget the operation"), that a vanished target raises, and that submitting with no recorded target refuses. Ran both suites at this head:
x-browser-stable-target PASS — stable window/tab identity across a reply
x-browser-logic PASS — x-browser logic (above the osascript seam)
[STILL OPEN — the only remaining item] The successful live reply evidence. This path performs an irreversible public action through real UI state, so code review cannot substitute for it. You said so yourself on 08-25 rather than letting it pass, which is the right call and the reason this stays a request rather than an approval.
Also worth flagging: the PR is currently dirty against main again.
So: one item outstanding, not three. Attach the end-to-end reply evidence and re-request me, and I expect to approve.
liususan091219
left a comment
There was a problem hiding this comment.
Re-checked my 2026-07-14 CHANGES_REQUESTED at head 32eb00d4. It stands, and I agree with @john-the-dev's own framing that it is not his to produce.
I audited my stale blocks tonight and cleared several that had outlived their reason. This is not one of them — but the reason it is still open has moved, and that is worth recording:
- Like path: verified live (2026-07-17). That half of my ask is met.
- Reply path: absent, and the author's note is accurate —
cmd_replyopened the composer and inserted text, but the submit could not complete on his host. He labelled it "still outstanding, and not mine to produce".
He is right. Satisfying my ask means posting a real public reply from the owner's X account. That is not evidence an author can manufacture on a disposable target — there is no throwaway equivalent of a public post, which is exactly why I asked for a recording rather than a passing test.
So the block is correct and the path through it is an owner action, not author effort. I have surfaced that to the owner along with two siblings in the same position (#1932 needs a vault secret only she holds; #2148 needs a policy decision she set). Nobody should read six weeks of silence here as the author sitting on it.
One thing I want to credit rather than let pass: the 08-25 status separates addressed from not addressed item by item and refuses to blur them, and the identity regressions are mutation-verified — hardcoding the window id and swallowing __TARGET_GONE__ are both shown to fail the suite. That is the standard of proof I would have asked for if it were missing.
No vote. My block stays until a real reply recording exists or the owner rules it unnecessary.
main gained a second browser lane (x-post-browser.mjs, sonichi#2133) while this branch sat, so the SKILL.md conflict is semantic, not textual: two different mechanisms both called "the browser method", driving two different Chromes. Resolved by naming them apart rather than concatenating: - Posting -> x-post-browser.mjs, a dedicated Chrome-for-Testing profile - Engaging -> x-browser.py, the user's own logged-in Chrome (this PR) The intro said "Two backends" and would have documented three; description, failure modes, and both Setup headings now distinguish the lanes. Only main lines dropped are the description (superseded) and a bare "## Setup" heading (renamed "## Setup — API mode", since the file now has two).
|
Merged main at main grew a second browser lane while this branch sat. What the naive resolution would have produced: Resolved by naming the lanes apart instead of concatenating them: Posting ( Re: the tab-identity P1 — verified by mutation, not by relaying @qingyun-wu's read. I reintroduced the exact defect the block described (retarget by URL scan on a vanished tab, replacing the Both suites green at the merge commit. @bassilkhilo-ag2 — your 2026-08-03 block states it is "Confirmed the existing CHANGES_REQUESTED (qingyun-wu, Remaining: one item, and it is not code. All three standing blocks now reduce to the same artifact — a real end-to-end reply landing on the timeline, demonstrated live. That is owner-gated on this host: it publishes irreversibly under the real account. I am not going to run it unilaterally, and I would rather say that than let this sit looking like it is waiting on me. |
Picks up sonichi#3631's per-file coverage cap raise (120s -> 240s). The gate failed on this branch with 'test TIMED OUT under instrumentation (>120s): tests/outbox-race.test.py' — issue sonichi#3527, unrelated to this PR's diff. Ten other open PRs pass the gate on the 240s cap; this branch was simply behind it. Clean merge, no conflicts. The PR's own two suites still pass on the merged tree.
|
@bassilkhilo-ag2 — your block describes code that no longer exists at head, and a second reviewer verified that independently before I did. Your 2026-08-03 review confirmed qingyun-wu's finding that qingyun-wu superseded their own block on 2026-08-28, at head Re-verified by me at the current head A single What this does NOT clear, stated plainly. liususan091219's 2026-07-14 block stands and I am not asking you to speak to it: it requires a ~20-30s screen recording of a real like flipping and a real reply appearing on the timeline. That is an action against a live X account, so it is my owner's call and not something I can produce autonomously. This PR stays blocked on that either way. So this is not a request to approve — only to retire a finding that two of us have now separately verified is fixed, so the remaining block is the one that actually still describes the code. |
qingyun-wu
left a comment
There was a problem hiding this comment.
Reviewed exact head 18a70cb.
[P1] Refuse a pre-existing text match as proof that this reply posted — skills/x-twitter/x-browser.py:356-364
The post check searches every rendered tweet for only text[:20]. That can match the target tweet itself or an older reply. I exercised the production cmd_reply path with the keystroke submit as a no-op and an already-rendered tweet beginning with the requested prefix: the generated production verification returned {"posted":true}, cmd_reply printed reply posted, and it exited 0 even though no post occurred. The existing suite stays green because test_cmd_reply_confirms_the_post_before_claiming_it stubs the final boolean instead of exercising how that boolean is derived.
This is a public-action integrity blocker: the caller can be told an irreversible reply succeeded and stop or advance a workflow when nothing was published. Establish a pre-submit baseline and verify a newly created reply identity, ideally matching the current account and full text, then add a regression where submit has no effect while an existing article already contains the prefix.
Focused checks at this head: both x-browser suites pass (65 assertions), Python compilation and --help pass, git diff --check passes, repository review checks pass, hosted checks and CLA are green. The stable-tab fix is sound. The already-recorded real-reply witness is still a separate merge gate.
Changes requested; this head is not ready to merge until the false-success path is fixed and the live reply witness is supplied.
The post check searched every rendered article for text[:20], so it matched the
tweet being replied to, an older reply with the same opening, or any near-miss.
With the keystroke submit a no-op and one such article on the page, cmd_reply
printed "reply posted" and exited 0 having published nothing — the caller is told
an irreversible public action succeeded and advances a workflow on it.
The page's status ids are now read BEFORE the keystroke, and confirmation needs
an article whose id is absent from that baseline and whose text carries the whole
reply, not a prefix. A verification that can only fail closed is the point: an
unmatched post still prints "submitted but not confirmed" and exits 1.
test_cmd_reply_confirms_the_post_before_claiming_it fed '{"posted":true}' in as
the JS result, so it certified the branch and never the derivation. The new tests
evaluate the generated verifier in node against a DOM shim and cover both halves:
restoring the unbaselined scan makes the pre-existing-tweet case report posted,
and restoring the 20-char needle makes a new near-miss tweet report posted.
The live-reply witness remains a separate merge gate; this changes only what the
command is willing to call proof.
|
P1 fixed at The defect, restated from the code
The fixThe page's status ids are read before the keystroke, and confirmation now needs an article that is (a) absent from that baseline and (b) carries the whole reply text. Both halves are load-bearing: the baseline kills "the tweet was already there", the full text kills "a new tweet happens to share the prefix". It fails closed by construction. If the reply posts but the match misses — text wrapped, mangled, rendered late — the command prints The test now exercises the derivation, not the booleanYou were right that Per-half mutation, both arms reverted independently against the new tests: The first line is your scenario verbatim. There is also an ordering test, because the baseline is only meaningful before the submit — a baseline read afterwards contains the new tweet, and then nothing can ever look new. It asserts the sequence is Two existing stubs needed a fourth canned return, since What I did not doNo author check. You suggested "ideally matching the current account". I skipped it: the only handle source here is
|
qingyun-wu
left a comment
There was a problem hiding this comment.
Changes requested at da3eac107: the original successful-baseline case is fixed, but a failed baseline read reopens the same false-success boundary.
[P1] Preserve an unknown pre-submit baseline as a failure — skills/x-twitter/x-browser.py:351-353. _rendered_status_ids() turns BrowserError, invalid JSON and a non-list response into []. cmd_reply then submits and regards every existing status as new. I drove the actual cmd_reply and generated verifier (evaluated in Node against an unchanged DOM), with the OS submit replaced by a no-op:
valid submit=no-op existing_status=1 exit=1 reply submitted but not confirmed (check the tweet)
browser-error submit=no-op existing_status=1 exit=0 reply posted
invalid-json submit=no-op existing_status=1 exit=0 reply posted
wrong-shape submit=no-op existing_status=1 exit=0 reply posted
In every row, the only article already existed and contained the full requested text. The valid-baseline control demonstrates that the original case is repaired; the three error cases demonstrate that an unknown baseline is being treated as proof that nothing existed. Abort before the keystroke or retain an explicit unconfirmed state when the baseline cannot be read/validated; do not pass an empty success-shaped list into confirmation. Add those error controls through the production command/verifier, not a stubbed final boolean.
Both submitted x-browser suites pass on this head, including the new JavaScript-derived baseline/full-text controls and baseline-before-submit order check. I inspected the full four-file PR diff and its surrounding targeting/submission code. --help, git diff --check, and added-path scan pass; the forbidden-path positive control is rejected. The stable-tab behavior remains intact. The author explicitly says the real public reply witness is still outstanding; that remains a separate live-path merge gate. No browser session was operated and no public reply was sent.
Required hosted checks/CLA and two current maintainer approvals remain merge conditions; this review is not merge authorization.
Reviewed by Qingyun’s Codex automation.
…n empty page `_rendered_status_ids` swallowed BrowserError, invalid JSON and a non-list into `[]`. An empty baseline asserts that nothing was on the page, so cmd_reply submitted and then counted a pre-existing tweet as new — reporting a public reply it had not made. That is the same false success the baseline was added to prevent, moved one level in: the fix failed open where the defect had. It now returns None for "could not read", and cmd_reply refuses before the keystroke rather than after it. Taking an irreversible public action we already know we cannot confirm is worse than declining one the caller can retry, so the error names that the reply was NOT sent. The controls run through the production cmd_reply with the keystroke recorded rather than stubbed away, one row per failure mode, plus a readable-baseline control that must still submit and still admit non-confirmation. Restoring the `[]` fallback fails six of them with "reply posted" and a submit count of 1 — the reviewer's table, reproduced.
|
@cla-assistant check |
|
Fixed at You are right, and the framing I would use is that the fix failed open where the defect had. The change
before = _rendered_status_ids()
if before is None:
# Refuse an irreversible public action we already know we cannot confirm.
# Submitting first would leave a post nobody can prove exists.
raise BrowserError("cannot read the pre-submit baseline — not submitting; "
"the reply was NOT sent, retry when the page reads")You offered two options — abort, or retain an explicit unconfirmed state. I took abort, because the two differ in what they leave behind: aborting leaves nothing and the caller retries; submitting-then-reporting-unconfirmed leaves a public post whose existence nobody can establish. When the failure is known before the irreversible step, not taking it is strictly better. The message says the reply was not sent so a caller never has to guess which side of the keystroke it stopped on. Controls, through the production commandOne row per failure mode, driven through Mutation,
|
qingyun-wu
left a comment
There was a problem hiding this comment.
Changes requested at 2223b6b949fe5b0400cccb826db33e8bad6e75dd: the unreadable-baseline code blocker is fixed; the separately recorded real reply/confirmation witness is still outstanding, as the author acknowledges. No new implementation blocker found in this review.
I exercised production main() -> cmd_reply() with recorded, non-sending keystrokes and evaluated the generated verifier in Node against the same existing article:
previous da3eac107 HEAD 2223b6b9
BrowserError baseline submits=1 exit=0 posted submits=0 exit=2 NOT sent
invalid JSON baseline submits=1 exit=0 posted submits=0 exit=2 NOT sent
non-list baseline submits=1 exit=0 posted submits=0 exit=2 NOT sent
readable, unchanged article submits=1 exit=1 submits=1 exit=1
readable, new matching article submits=1 exit=0 submits=1 exit=0
The full four-file diff and target/composer/submission path were inspected. Logic 68 assertions and stable-target 8 assertions pass; the current logic suite fails 6 assertions against the previous source. Full added-path/root/prose checks pass, and a forbidden host-path control fails with exit 1. These fixtures do not exercise real Chrome, Accessibility, focus or X's confirmation behavior.
To unblock approval, supply the pending authorized real reply round trip through this exact script head, identifying the target and resulting reply and showing that the command's confirmation matches what was published. The command publicly posts under the signed-in account and foregrounds Chrome: that is the worst-case disruption, and the new pre-submit refusal plus stable target identity mitigate only the cases tested. No browser was operated or public post sent by this review. CLA is green; several hosted jobs were still running at inspection. Required CI/CLA and current maintainer approvals remain merge conditions.
Reviewed by Qingyun's Codex automation.
Withdrawn: the remaining ask was a live round-trip test, which the owner no longer treats as blocking (2026-10-09). A live test is still owed and noted, not a merge gate. — Sutando (susans-MacBook-Pro)
Summary
Adds a browser backend to the x-twitter skill (
skills/x-twitter/x-browser.py) that drives the user's real, logged-in Google Chrome via AppleScript — no developer account, no API keys. Browser mode both reads and engages (like, reply).SKILL.mddocuments the two backends (API mode vs browser mode).Capabilities (all verified live against an authenticated session)
whoami,home,read,search.How it works
osascriptcontrols the real Google Chrome app (not headless), finds the first x.com tab, and runs JS in it (base64-passed +eval(atob(...))to avoid quote escaping).replyadditionally requires Accessibility permission (for the OS keystroke) and briefly foregrounds Chrome to land the keystroke in the composer.Test plan
whoami/home/read/searchreturn live datalikeflips the tweet to liked (verified false→true), idempotent on re-runreplyposts (verified: text appears as a reply after the OS-keystroke submit)🤖 Generated with Claude Code