<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Vinh Nguyen</title>
    <description>The latest articles on DEV Community by Vinh Nguyen (@vinhnguyenthanhdn).</description>
    <link>https://dev.to/vinhnguyenthanhdn</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3996295%2Fb7f14877-ff5b-4a11-9079-1527575e0cce.jpg</url>
      <title>DEV Community: Vinh Nguyen</title>
      <link>https://dev.to/vinhnguyenthanhdn</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/vinhnguyenthanhdn"/>
    <language>en</language>
    <item>
      <title>Your automation is not logged out: a missing `--cdp` flag started a second Chrome</title>
      <dc:creator>Vinh Nguyen</dc:creator>
      <pubDate>Wed, 02 Sep 2026 00:08:08 +0000</pubDate>
      <link>https://dev.to/vinhnguyenthanhdn/your-automation-is-not-logged-out-a-missing-cdp-flag-started-a-second-chrome-207a</link>
      <guid>https://dev.to/vinhnguyenthanhdn/your-automation-is-not-logged-out-a-missing-cdp-flag-started-a-second-chrome-207a</guid>
      <description>&lt;p&gt;A scheduled job of mine drives a real Chrome profile that stays signed in to DEV, because the API can read comments but cannot create them. One run came back with the dashboard replaced by the sign-in page: the log said it had opened &lt;code&gt;https://dev.to/dashboard&lt;/code&gt;, and what it actually landed on was &lt;code&gt;https://dev.to/magic_links/new&lt;/code&gt;, with zero links to my own profile anywhere in the DOM.&lt;/p&gt;

&lt;p&gt;The profile itself was fine. A probe against the debugging port at the same moment returned a live Chrome, and the dashboard fetched through that port rendered the account's own identity links normally. Two browsers, same machine, same minute, opposite answers.&lt;/p&gt;

&lt;h2&gt;
  
  
  The three things worth checking first, and why they miss
&lt;/h2&gt;

&lt;p&gt;The session expired. That is the reflex, and it is also the one that makes you re-authenticate for no reason and burn the logged-in state you were trying to protect.&lt;/p&gt;

&lt;p&gt;Cookies got cleared by a Chrome update. Same family, same cost if you act on it.&lt;/p&gt;

&lt;p&gt;The debug port died and the tool fell back to something else. This one is close enough to be dangerous, because it names the right layer — &lt;em&gt;which browser am I attached to&lt;/em&gt; — and then picks the wrong cause inside it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it actually goes wrong
&lt;/h2&gt;

&lt;p&gt;It is one argument.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;agent-browser&lt;/code&gt; attaches to an already-running Chrome when you pass &lt;code&gt;--cdp &amp;lt;port&amp;gt;&lt;/code&gt;. Leave the flag off and it starts its own browser, with its own empty profile directory, and drives that one instead. Everything downstream still works — it navigates, waits, evaluates, returns a page. It just does all of that in a browser that has never logged in to anything.&lt;/p&gt;

&lt;p&gt;So the automation is not looking at an expired session. It is looking at a different browser's logged-out session, and reporting it in exactly the shape a real logout would take.&lt;/p&gt;

&lt;h2&gt;
  
  
  The two failure modes do not look alike, and that is the trap
&lt;/h2&gt;

&lt;p&gt;Here is what I measured today, on Chrome 152.0.7977.65 with the current npx build.&lt;/p&gt;

&lt;p&gt;Pass the flag, but point it at a port nothing is listening on:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx &lt;span class="nt"&gt;-y&lt;/span&gt; agent-browser open &lt;span class="s2"&gt;"https://dev.to/dashboard"&lt;/span&gt; &lt;span class="nt"&gt;--cdp&lt;/span&gt; 49299
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="nv"&gt;$?&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;✗ All CDP discovery methods failed for 127.0.0.1:49299: /json/version: Failed to
connect to CDP at 127.0.0.1:49299 ...; WebSocket: WebSocket connect failed at
ws://127.0.0.1:49299/devtools/browser: IO error: Connection refused (os error 61)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Exit code 1. Nothing launched. This branch cannot hurt you, because it stops.&lt;/p&gt;

&lt;p&gt;Now leave the flag off entirely:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx &lt;span class="nt"&gt;-y&lt;/span&gt; agent-browser open &lt;span class="s2"&gt;"https://dev.to/dashboard"&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="nv"&gt;$?&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;[agent-browser] launched browser
✓
  https://dev.to/magic_links/new
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Exit code 0. That is a success by every check a wrapper script is likely to run.&lt;/p&gt;

&lt;p&gt;The inversion is the whole problem. A dead port announces itself and halts. A missing argument — the case you will actually hit, since nobody types a dead port on purpose — succeeds quietly and hands back a browser that is not yours. And because the symptom presents as "suddenly logged out", the instinct is to go hunting for a dead port, which is the one branch that would have told you out loud.&lt;/p&gt;

&lt;p&gt;The tell is that single line, &lt;code&gt;launched browser&lt;/code&gt;. If it shows up, or &lt;code&gt;relaunched browser&lt;/code&gt; does, you are not on your profile, whatever the rest of the run reports.&lt;/p&gt;

&lt;h2&gt;
  
  
  A port that answers is not a profile that matches
&lt;/h2&gt;

&lt;p&gt;The natural hardening step is to probe the port before running anything:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; http://127.0.0.1:49224/json/version
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I do run that and it is worth running, but it does not prove what it appears to prove. It establishes that some Chrome is listening there. It says nothing about whether that Chrome is holding your session.&lt;/p&gt;

&lt;p&gt;The check that settles it reads the profile directory off the process bound to that port:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ps aux | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="s2"&gt;"[C]hrome"&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="s2"&gt;"--remote-debugging-port=49224"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="s2"&gt;"--user-data-dir=[^ ]*"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Today that returns the profile directory I expect, the port reports &lt;code&gt;Chrome/152.0.7977.65&lt;/code&gt;, and the dashboard renders 27 links to my own account. Those three together are an identity. Any one of them on its own is not.&lt;/p&gt;

&lt;h2&gt;
  
  
  The variant that costs more than a wasted run
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;connect&lt;/code&gt; deserves separate care, because it takes the port as a positional argument, which reads as though the port has already been supplied:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx &lt;span class="nt"&gt;-y&lt;/span&gt; agent-browser connect 49224     &lt;span class="c"&gt;# not enough&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It still wants &lt;code&gt;--cdp&lt;/code&gt;. Without it, one run of mine replaced the live Chrome with a scratch profile and the real window was gone. A separate run of the same command printed &lt;code&gt;relaunched browser&lt;/code&gt; while the port kept answering normally — which is where a live port sitting in front of the wrong profile comes from.&lt;/p&gt;

&lt;p&gt;Pass &lt;code&gt;--cdp &amp;lt;port&amp;gt;&lt;/code&gt; on every command, including the one that already has the port sitting in its arguments.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this is bounded to
&lt;/h2&gt;

&lt;p&gt;Measured on macOS with Chrome 152.0.7977.65 and the npx build of &lt;code&gt;agent-browser&lt;/code&gt; as of today. The dead-port and missing-flag branches were both re-run this morning and behaved as described above; the &lt;code&gt;connect&lt;/code&gt; behaviour is from two earlier runs and I have not re-triggered it, for the obvious reason that reproducing it costs a live session.&lt;/p&gt;

&lt;p&gt;Exit codes and log strings are properties of one CLI at one version, so treat them as a shape to look for rather than constants to assert on. What generalises is narrower than the tool: if a browser automation library is capable of starting a browser for you, then a page that says you are signed out is telling you the truth about the browser you got, which is not necessarily the browser you asked for.&lt;/p&gt;

</description>
      <category>automation</category>
      <category>testing</category>
      <category>webdev</category>
      <category>programming</category>
    </item>
    <item>
      <title>A LaunchAgent gets `Operation not permitted` for `~/Documents` while Terminal works</title>
      <dc:creator>Vinh Nguyen</dc:creator>
      <pubDate>Wed, 26 Aug 2026 00:22:04 +0000</pubDate>
      <link>https://dev.to/vinhnguyenthanhdn/a-launchagent-gets-operation-not-permitted-for-documents-while-terminal-works-2df4</link>
      <guid>https://dev.to/vinhnguyenthanhdn/a-launchagent-gets-operation-not-permitted-for-documents-while-terminal-works-2df4</guid>
      <description>&lt;p&gt;The same zsh script could list &lt;code&gt;~/Documents&lt;/code&gt; when I ran it in Terminal. Started as a LaunchAgent, it failed with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ls: /Users/administrator/Documents: Operation not permitted
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The LaunchAgent had the same user ID, the same &lt;code&gt;$HOME&lt;/code&gt;, and the same script. That combination makes this look like a Unix permission problem. In this test it was not. The useful discriminator was the launch context: access succeeded from Terminal, failed from &lt;code&gt;launchd&lt;/code&gt;, and still succeeded for a path outside the protected folder.&lt;/p&gt;

&lt;p&gt;I reproduced this on macOS 15.6.1 (Darwin 24.6.0) with a LaunchAgent in &lt;code&gt;gui/501&lt;/code&gt;. The probe was removed after the test.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why &lt;code&gt;chmod&lt;/code&gt; is the wrong first check
&lt;/h2&gt;

&lt;p&gt;The obvious suspects were file ownership, a wrong home directory, or a job running as another user. The probe printed those facts before touching the files:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/bin/zsh&lt;/span&gt;

print &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="s2"&gt;"user=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt; &lt;span class="nt"&gt;-un&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt; uid=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
print &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="s2"&gt;"home=&lt;/span&gt;&lt;span class="nv"&gt;$HOME&lt;/span&gt;&lt;span class="s2"&gt; pwd=&lt;/span&gt;&lt;span class="nv"&gt;$PWD&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

/bin/ls &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$HOME&lt;/span&gt;&lt;span class="s2"&gt;/Documents"&lt;/span&gt; 2&amp;gt;&amp;amp;1 | /usr/bin/head &lt;span class="nt"&gt;-5&lt;/span&gt;
/bin/cat &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$HOME&lt;/span&gt;&lt;span class="s2"&gt;/Documents/vinh/working/CLAUDE.md"&lt;/span&gt; 2&amp;gt;&amp;amp;1 | /usr/bin/head &lt;span class="nt"&gt;-1&lt;/span&gt;

&lt;span class="c"&gt;# Negative control: outside Documents&lt;/span&gt;
/bin/ls &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$HOME&lt;/span&gt;&lt;span class="s2"&gt;/.pf004"&lt;/span&gt; 2&amp;gt;&amp;amp;1 | /usr/bin/head &lt;span class="nt"&gt;-5&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The two runs produced this difference:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Check&lt;/th&gt;
&lt;th&gt;Terminal&lt;/th&gt;
&lt;th&gt;LaunchAgent in &lt;code&gt;gui/501&lt;/code&gt;
&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;User / uid&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;administrator&lt;/code&gt; / &lt;code&gt;501&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;administrator&lt;/code&gt; / &lt;code&gt;501&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;$HOME&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/Users/administrator&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/Users/administrator&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ls ~/Documents&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Listed entries&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Operation not permitted&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;cat&lt;/code&gt; inside &lt;code&gt;~/Documents&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Read the file&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Operation not permitted&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ls ~/.pf004&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Listed entries&lt;/td&gt;
&lt;td&gt;Listed entries&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The working directory differed, but the script used absolute paths under &lt;code&gt;$HOME&lt;/code&gt;, so &lt;code&gt;PWD=/&lt;/code&gt; did not explain the denial. The negative control mattered more: the LaunchAgent could read another directory owned by the same user. Changing ownership or mode bits would not explain why only the launch context changed the result.&lt;/p&gt;

&lt;h2&gt;
  
  
  The owning layer is the privacy context
&lt;/h2&gt;

&lt;p&gt;On this machine, the access decision was attached to how the process was launched, not just to uid 501. Terminal had a privacy context that allowed access to the user's Documents folder. The process started by &lt;code&gt;launchd&lt;/code&gt; did not inherit that access.&lt;/p&gt;

&lt;p&gt;That is why these observations can all be true at once:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;id -u&lt;/code&gt; reports the expected user.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;$HOME&lt;/code&gt; points to the expected home directory.&lt;/li&gt;
&lt;li&gt;Ordinary files elsewhere under that home directory are readable.&lt;/li&gt;
&lt;li&gt;A read under &lt;code&gt;~/Documents&lt;/code&gt; returns &lt;code&gt;Operation not permitted&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The smallest diagnostic is therefore not another &lt;code&gt;chmod&lt;/code&gt;. Run one identical read from the interactive application and from the scheduled process, then add a control path outside Desktop, Documents, or Downloads. If the user and path are correct, the control succeeds, and only the protected folder fails, investigate the macOS privacy context of the scheduled process.&lt;/p&gt;

&lt;h2&gt;
  
  
  A pipeline can hide the denial
&lt;/h2&gt;

&lt;p&gt;My first probe contained this line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;/bin/ls &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$HOME&lt;/span&gt;&lt;span class="s2"&gt;/Documents"&lt;/span&gt; 2&amp;gt;&amp;amp;1 | /usr/bin/head &lt;span class="nt"&gt;-5&lt;/span&gt;
print &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="s2"&gt;"status=&lt;/span&gt;&lt;span class="nv"&gt;$?&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It printed &lt;code&gt;Operation not permitted&lt;/code&gt; and then printed &lt;code&gt;status=0&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;In zsh, the default status of a pipeline is the status of its last command. &lt;code&gt;head&lt;/code&gt; successfully printed the error text, so the pipeline looked successful even though &lt;code&gt;ls&lt;/code&gt; failed. This is enough to turn a visible privacy denial into missing downstream data.&lt;/p&gt;

&lt;p&gt;For a diagnostic script, enable pipeline failure before the probe:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; pipefail

/bin/ls &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$HOME&lt;/span&gt;&lt;span class="s2"&gt;/Documents"&lt;/span&gt; 2&amp;gt;&amp;amp;1 | /usr/bin/head &lt;span class="nt"&gt;-5&lt;/span&gt;
print &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="s2"&gt;"status=&lt;/span&gt;&lt;span class="nv"&gt;$?&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I checked the behavior with a minimal control: &lt;code&gt;/usr/bin/false | /usr/bin/head -1&lt;/code&gt; returned &lt;code&gt;0&lt;/code&gt; by default and &lt;code&gt;1&lt;/code&gt; with &lt;code&gt;pipefail&lt;/code&gt; enabled. In a larger job, capture and handle the status immediately rather than letting another command overwrite &lt;code&gt;$?&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to change in an unattended job
&lt;/h2&gt;

&lt;p&gt;If the job does not need a protected folder, move its required inputs and state to an unprotected application directory. The negative-control path under &lt;code&gt;~/.pf004&lt;/code&gt; was readable from both launch contexts in this test.&lt;/p&gt;

&lt;p&gt;If the job must read Documents, treat that as a separate permission requirement and test it from the actual LaunchAgent. A successful Terminal run is not proof that the scheduled process has the same access. I did not test Full Disk Access, a system LaunchDaemon, Desktop, or Downloads, so this result does not establish how those configurations behave.&lt;/p&gt;

&lt;p&gt;The practical rule is narrow: same uid and same &lt;code&gt;$HOME&lt;/code&gt; do not make Terminal and a LaunchAgent equivalent readers of &lt;code&gt;~/Documents&lt;/code&gt;. Compare the launch contexts, keep an outside-path negative control, and make the pipeline report the command that actually failed.&lt;/p&gt;

</description>
      <category>macos</category>
      <category>devops</category>
      <category>automation</category>
      <category>programming</category>
    </item>
    <item>
      <title>The DEV API drops the "published" field on the endpoint you would check it from</title>
      <dc:creator>Vinh Nguyen</dc:creator>
      <pubDate>Sun, 23 Aug 2026 00:03:24 +0000</pubDate>
      <link>https://dev.to/vinhnguyenthanhdn/the-dev-api-drops-the-published-field-on-the-endpoint-you-would-check-it-from-4l7i</link>
      <guid>https://dev.to/vinhnguyenthanhdn/the-dev-api-drops-the-published-field-on-the-endpoint-you-would-check-it-from-4l7i</guid>
      <description>&lt;p&gt;If you script your posting to dev.to, the obvious way to confirm a post went live is to read &lt;code&gt;published&lt;/code&gt; back from the API. On the endpoint you would naturally use for that, the field is not &lt;code&gt;false&lt;/code&gt; and it is not &lt;code&gt;null&lt;/code&gt;. It is not in the response at all, so &lt;code&gt;article.get("published")&lt;/code&gt; hands you &lt;code&gt;None&lt;/code&gt; and your script decides a live article is still a draft.&lt;/p&gt;

&lt;p&gt;The reliable check is simpler: use an unauthenticated &lt;code&gt;GET /api/articles/{id}&lt;/code&gt; returning HTTP 200 as proof that a stranger can read the article. Use the author-scoped collections when you need to distinguish drafts from published posts.&lt;/p&gt;

&lt;p&gt;Everything below was measured on 2026-08-21 against dev.to with API v1, on my own account, with three published articles and one draft.&lt;/p&gt;

&lt;h2&gt;
  
  
  One article, two shapes
&lt;/h2&gt;

&lt;p&gt;The author-scoped list endpoint carries the field:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"https://dev.to/api/articles/me/published?per_page=1"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"api-key: &lt;/span&gt;&lt;span class="nv"&gt;$DEVTO_KEY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"accept: application/vnd.forem.api-v1+json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
| python3 &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'import sys,json;a=json.load(sys.stdin)[0];print(a["id"], a["published"], a["published_at"])'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;4408696 True 2026-08-16T07:13:13.534Z
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The single-article endpoint, same key, same article id, does not:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"https://dev.to/api/articles/4408696"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"api-key: &lt;/span&gt;&lt;span class="nv"&gt;$DEVTO_KEY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"accept: application/vnd.forem.api-v1+json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
| python3 &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'import sys,json;a=json.load(sys.stdin);print("published" in a, a.get("published_at"))'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;False 2026-08-16T07:13:13Z
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I ran that request three ways, with the api-key header, without it, and without the vendor &lt;code&gt;accept&lt;/code&gt; header, and &lt;code&gt;"published" in a&lt;/code&gt; was &lt;code&gt;False&lt;/code&gt; every time. So this is not a permissions difference between an author and a stranger. The key does not change it.&lt;/p&gt;

&lt;p&gt;The write path is the same shape. A &lt;code&gt;PUT /api/articles/{id}&lt;/code&gt; returned &lt;code&gt;HTTP 200&lt;/code&gt; and a body with no &lt;code&gt;published&lt;/code&gt; key either, even though &lt;code&gt;published&lt;/code&gt; is a field I had just sent in the request.&lt;/p&gt;

&lt;p&gt;Which leaves the two ways a script gets this wrong. &lt;code&gt;article["published"]&lt;/code&gt; raises &lt;code&gt;KeyError&lt;/code&gt; on a successful publish, and &lt;code&gt;article.get("published")&lt;/code&gt; quietly reports a public article as unpublished, which is worse, because that branch usually retries.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;published&lt;/code&gt; is an input. You send it on &lt;code&gt;POST /api/articles&lt;/code&gt; and on &lt;code&gt;PUT /api/articles/{id}&lt;/code&gt; to move a draft to public. The collections under &lt;code&gt;/api/articles/me/*&lt;/code&gt; are author-scoped, so they describe your article the way its owner sees it and echo the flag back.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;GET /api/articles/{id}&lt;/code&gt; is a different representation. It serves the article the way an anonymous reader receives it, and in that view there is no publication state to report, because an article you can fetch there is by definition one that is published.&lt;/p&gt;

&lt;h2&gt;
  
  
  The negative control
&lt;/h2&gt;

&lt;p&gt;That last sentence is the part worth testing rather than assuming, so I asked the endpoint for something the public cannot see. I created a draft with &lt;code&gt;"published": false&lt;/code&gt;, took its id from &lt;code&gt;GET /api/articles/me/unpublished&lt;/code&gt;, and requested it directly.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s2"&gt;"%{http_code}&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"https://dev.to/api/articles/4449977"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;404
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then the same request with my own key, for my own draft:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s2"&gt;" HTTP %{http_code}&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"https://dev.to/api/articles/4449977"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"api-key: &lt;/span&gt;&lt;span class="nv"&gt;$DEVTO_KEY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"accept: application/vnd.forem.api-v1+json"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"error"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"not found"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;404&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;HTTP&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;404&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;My draft, my key, still 404. The endpoint has nothing to say about anything that is not public, which is why the flag is missing rather than false.&lt;/p&gt;

&lt;h2&gt;
  
  
  Check the thing you actually care about
&lt;/h2&gt;

&lt;p&gt;The 404 is the useful half. It turns the presence of a response into the signal:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;code&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s2"&gt;"%{http_code}"&lt;/span&gt; &lt;span class="s2"&gt;"https://dev.to/api/articles/&lt;/span&gt;&lt;span class="nv"&gt;$ID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$code&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"200"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"not public: HTTP &lt;/span&gt;&lt;span class="nv"&gt;$code&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;exit &lt;/span&gt;1&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An unauthenticated 200 means a stranger can read the article. That is the property you were after when you went looking for &lt;code&gt;published&lt;/code&gt;, and it is a stronger check than a boolean echoed back from your own request, because nothing about it depends on your credentials. Take the timestamp from &lt;code&gt;published_at&lt;/code&gt; on that same response.&lt;/p&gt;

&lt;p&gt;If you need the other direction, the list of what is still a draft, ask the author-scoped endpoint. &lt;code&gt;GET /api/articles/me/unpublished&lt;/code&gt; returned &lt;code&gt;published: false&lt;/code&gt; for my draft and &lt;code&gt;me/published&lt;/code&gt; returns &lt;code&gt;true&lt;/code&gt;. Those endpoints answer the ownership question. The id endpoint does not, and the missing key is the only notice you get.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this was tested
&lt;/h2&gt;

&lt;p&gt;dev.to itself, API v1, with the &lt;code&gt;accept: application/vnd.forem.api-v1+json&lt;/code&gt; header, on 2026-08-21. I did not test a self-hosted Forem instance, an organization article, or a scheduled post, and a self-hosted instance can be several versions behind. If the field starts appearing on the id endpoint later, the unauthenticated 200 check keeps working regardless, which is why my own publishing script gates on that instead of on the flag.&lt;/p&gt;

&lt;p&gt;If you run the same check against a self-hosted Forem or a scheduled post, I would be interested in the response shape and Forem version. Those are the two cases I have not tested.&lt;/p&gt;

</description>
      <category>programming</category>
      <category>api</category>
      <category>devto</category>
      <category>webdev</category>
    </item>
    <item>
      <title>If your saved numbers drift, check the scroll wheel before the database</title>
      <dc:creator>Vinh Nguyen</dc:creator>
      <pubDate>Sun, 16 Aug 2026 07:13:13 +0000</pubDate>
      <link>https://dev.to/vinhnguyenthanhdn/if-your-saved-numbers-drift-check-the-scroll-wheel-before-the-database-18hc</link>
      <guid>https://dev.to/vinhnguyenthanhdn/if-your-saved-numbers-drift-check-the-scroll-wheel-before-the-database-18hc</guid>
      <description>&lt;p&gt;Someone built a small clinical app with AI coding tools. You type a child's weight and length, and it returns Z-scores against the growth reference. It worked. Then users started reporting that saved values came back slightly higher than what they had entered.&lt;/p&gt;

&lt;p&gt;Small drift. A few tenths, sometimes a whole unit. The kind of number that looks like floating point, or a unit conversion, or a rounding rule applied twice.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fci3psl4ovh6665wovyc1.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fci3psl4ovh6665wovyc1.png" alt="Weight and length inputs with the Z-score table rendered directly below the form" width="797" height="447"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Three days went into finding it. Here is where they went, because the wrong turns are the useful part.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three clean layers
&lt;/h2&gt;

&lt;p&gt;The obvious suspects were checked in the obvious order.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rounding.&lt;/strong&gt; The display rounds to one decimal, the store keeps full precision. Read back, compared, no discrepancy.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The conversion before the write.&lt;/strong&gt; Values pass through a normalisation step on the way to storage. It was checked against its own inputs and outputs. Clean.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Field binding.&lt;/strong&gt; The classic: weight bound to the length field, or two inputs sharing one piece of state. Also clean.&lt;/p&gt;

&lt;p&gt;All three were clean, which is exactly why the search kept circling them. When a layer is innocent you do not cross it off, you assume you checked it badly and go around again.&lt;/p&gt;

&lt;h2&gt;
  
  
  The value was already wrong
&lt;/h2&gt;

&lt;p&gt;The mistake was in the framing, not in any of those checks. Every one of them asked "what happens to the number on its way to storage". Nobody asked what the number was when the save handler received it.&lt;/p&gt;

&lt;p&gt;It was already wrong.&lt;/p&gt;

&lt;p&gt;An &lt;code&gt;&amp;lt;input type="number"&amp;gt;&lt;/code&gt; that has focus consumes the wheel event, and the browser increments or decrements the value by one step per notch. This is standard, specified behaviour and it has been in browsers forever.&lt;/p&gt;

&lt;p&gt;Now look at the layout in the screenshot. The results table sits directly below the form. That is a perfectly reasonable design — you enter measurements, you read the interpretation underneath. It also means that after typing a weight, the natural next action is to scroll down to read the Z-scores, and the cursor is still sitting in the weight field.&lt;/p&gt;

&lt;p&gt;Every scroll edits the value that was just typed.&lt;/p&gt;

&lt;p&gt;The drift stays small because a couple of notches is a couple of steps, which is why it reads like a rounding artefact instead of an input bug. And it does not reproduce when a developer tests it, because a developer types a value, tabs out, and clicks the button. Nobody scrolls with the cursor parked in the field except a real user reading a result that happens to be below the fold.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix is one line
&lt;/h2&gt;

&lt;p&gt;Drop focus when the wheel fires:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;input&lt;/span&gt;
  &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"number"&lt;/span&gt;
  &lt;span class="na"&gt;onWheel&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;currentTarget&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;blur&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
  &lt;span class="err"&gt;...&lt;/span&gt;
&lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or stop using &lt;code&gt;type="number"&lt;/code&gt; for a value that is not really a spinner:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;input&lt;/span&gt;
  &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"text"&lt;/span&gt;
  &lt;span class="na"&gt;inputMode&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"decimal"&lt;/span&gt;
  &lt;span class="err"&gt;...&lt;/span&gt;
&lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The second keeps the numeric keypad on mobile, which is usually the only reason &lt;code&gt;type="number"&lt;/code&gt; was chosen in the first place. It also gives up the browser's built-in numeric validation, so whatever validation you were leaning on has to become explicit.&lt;/p&gt;

&lt;p&gt;Pick either. The interesting part is not the fix.&lt;/p&gt;

&lt;h2&gt;
  
  
  Nobody reviews code nobody asked for
&lt;/h2&gt;

&lt;p&gt;The person debugging this reviewed their code carefully. They just reviewed the code they had asked for.&lt;/p&gt;

&lt;p&gt;Scroll-to-increment was never requested. It is not a line anyone wrote. It arrived as a property of a default that got selected while generating a form — defensible in isolation, never specified, never reviewed, and invisible until a real hand touches a real mouse.&lt;/p&gt;

&lt;p&gt;This is the failure mode I keep meeting in applications assembled with AI coding tools, and it is not "the AI writes buggy code". The generated code was fine. Individually every choice in it was reasonable. The gap is that a specification never existed, so there is no document against which any of it is wrong, and no reviewer whose job it was to notice a behaviour nobody ordered.&lt;/p&gt;

&lt;p&gt;The bug report language made it worse, as it usually does. "The saved value changed" names a layer. It sends you to persistence, and persistence is where three days went. A report that said "the number changes while I am reading the results" would have been solved in ten minutes — but users do not report mechanisms, they report the last thing they saw.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part that generalises
&lt;/h2&gt;

&lt;p&gt;When you get a report that a stored value disagrees with what the user believes they entered, the search space is larger than the write path. Something upstream may be editing it: a wheel event, an autofill, a controlled component reformatting on re-render, an event handler firing on a parent.&lt;/p&gt;

&lt;p&gt;A cheap way to cut the search in half: log the value at the moment the handler receives it. If it is already wrong there, every layer below is innocent and you stop re-reading them.&lt;/p&gt;

&lt;p&gt;This is the difference between a prototype that works and a product that survives its users. The code was fine. The product was not.&lt;/p&gt;

&lt;p&gt;I am curious how common the split is: when a stored value disagreed with what your users thought they entered, how often did it turn out to be the write path, and how often was it something upstream of it?&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>debugging</category>
      <category>ai</category>
    </item>
    <item>
      <title>Exporting ANTHROPIC_BASE_URL does not reach the Claude Code panel in VSCode</title>
      <dc:creator>Vinh Nguyen</dc:creator>
      <pubDate>Sat, 15 Aug 2026 16:56:49 +0000</pubDate>
      <link>https://dev.to/vinhnguyenthanhdn/exporting-anthropicbaseurl-does-not-reach-the-claude-code-panel-in-vscode-49n9</link>
      <guid>https://dev.to/vinhnguyenthanhdn/exporting-anthropicbaseurl-does-not-reach-the-claude-code-panel-in-vscode-49n9</guid>
      <description>&lt;p&gt;&lt;code&gt;export ANTHROPIC_BASE_URL=http://127.0.0.1:20128&lt;/code&gt; in your shell routes the &lt;code&gt;claude&lt;/code&gt; CLI to a local endpoint. Open the Claude Code panel in VSCode in the same project and it still talks to Anthropic directly. The two live on different environment surfaces, and that is the whole problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the shell variable does not carry over
&lt;/h2&gt;

&lt;p&gt;The CLI reads the environment of the process you started it from. The VSCode panel does not: the extension spawns its agent process from the VSCode window process, so its environment is whatever VSCode itself was started with — the desktop/login environment, not the shell you typed &lt;code&gt;export&lt;/code&gt; into five seconds ago.&lt;/p&gt;

&lt;p&gt;Two consequences that bite in opposite directions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Export in a terminal, then open the panel: nothing happens. The panel never saw the variable.&lt;/li&gt;
&lt;li&gt;Launch VSCode &lt;em&gt;from&lt;/em&gt; that terminal instead, so it inherits the vars: now &lt;strong&gt;every&lt;/strong&gt; workspace in that window is routed, including the ones where you wanted the default Anthropic connection.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The escape hatch the extension gives you is a settings key, &lt;code&gt;claudeCode.environmentVariables&lt;/code&gt;, which is a list of &lt;code&gt;{name, value}&lt;/code&gt; pairs injected into the agent process. You can check what your panel actually sees right now — this runs anywhere &lt;code&gt;jq&lt;/code&gt; does:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# macOS&lt;/span&gt;
&lt;span class="nv"&gt;S&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$HOME&lt;/span&gt;&lt;span class="s2"&gt;/Library/Application Support/Code/User/settings.json"&lt;/span&gt;
&lt;span class="c"&gt;# Linux:   S="$HOME/.config/Code/User/settings.json"&lt;/span&gt;
&lt;span class="c"&gt;# Windows: S="$APPDATA/Code/User/settings.json"&lt;/span&gt;

jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.["claudeCode.environmentVariables"] // []
  | if length == 0
    then "panel env: (none set) -&amp;gt; extension inherits the VSCode process env"
    else (.[] | "\(.name)=\(if .name|test("TOKEN|KEY") then "***" else .value end)")
    end'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$S&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On a machine that has never been switched, that prints the &lt;code&gt;(none set)&lt;/code&gt; line. That is the honest answer to "why is my panel ignoring the variable".&lt;/p&gt;

&lt;h2&gt;
  
  
  Editing that key by hand is where it goes wrong
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;claudeCode.environmentVariables&lt;/code&gt; is a plain array in your user &lt;code&gt;settings.json&lt;/code&gt;, and hand-editing it has three failure modes I hit before writing anything:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;It has machine scope.&lt;/strong&gt; It is not per-workspace. Turn routing on for one project and every VSCode window on that machine is routed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Turning it off means deleting entries, not flipping a flag.&lt;/strong&gt; Leave &lt;code&gt;ANTHROPIC_BASE_URL&lt;/code&gt; behind while removing the token and the panel points at a router with no credential.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It shares the array with unrelated variables.&lt;/strong&gt; Anything else you put there — proxy settings, a &lt;code&gt;NODE_EXTRA_CA_CERTS&lt;/code&gt; — is one careless overwrite away from gone.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;So the toolkit I ended up with treats the array as &lt;em&gt;partly&lt;/em&gt; owned. It manages exactly six names:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;&lt;span class="err"&gt;ANTHROPIC_BASE_URL&lt;/span&gt;
&lt;span class="err"&gt;ANTHROPIC_AUTH_TOKEN&lt;/span&gt;
&lt;span class="err"&gt;ANTHROPIC_API_KEY&lt;/span&gt;
&lt;span class="err"&gt;ANTHROPIC_MODEL&lt;/span&gt;
&lt;span class="err"&gt;ANTHROPIC_SMALL_FAST_MODEL&lt;/span&gt;
&lt;span class="err"&gt;CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Entries whose name is not on that list are read, kept aside, and written back untouched. Before any write it copies &lt;code&gt;settings.json&lt;/code&gt; to &lt;code&gt;settings.json.bak-claude-router&lt;/code&gt;, and if serialisation throws it restores the backup and exits non-zero rather than leaving you with half a settings file.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$&lt;/span&gt;&lt;span class="nn"&gt;env&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="nv"&gt;USERPROFILE&lt;/span&gt;&lt;span class="s2"&gt;\.claude\9router\vscode-switch.ps1"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;on&lt;/span&gt;&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="c"&gt;# inject the six&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$&lt;/span&gt;&lt;span class="nn"&gt;env&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="nv"&gt;USERPROFILE&lt;/span&gt;&lt;span class="s2"&gt;\.claude\9router\vscode-switch.ps1"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="c"&gt;# print base URL + model, never the token&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$&lt;/span&gt;&lt;span class="nn"&gt;env&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="nv"&gt;USERPROFILE&lt;/span&gt;&lt;span class="s2"&gt;\.claude\9router\vscode-switch.ps1"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;off&lt;/span&gt;&lt;span class="w"&gt;     &lt;/span&gt;&lt;span class="c"&gt;# remove only the six&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;on&lt;/code&gt; and &lt;code&gt;off&lt;/code&gt; need a &lt;strong&gt;Developer: Reload Window&lt;/strong&gt; afterwards — the agent process reads its environment once at spawn.&lt;/p&gt;

&lt;h2&gt;
  
  
  The terminal side keeps the opposite property
&lt;/h2&gt;

&lt;p&gt;For the CLI the right scope is the narrow one, so there routing is per-process and nothing is persisted:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;claude&lt;/span&gt;&lt;span class="w"&gt;                    &lt;/span&gt;&lt;span class="c"&gt;# default Anthropic connection&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="n"&gt;claude-9router&lt;/span&gt;&lt;span class="w"&gt;            &lt;/span&gt;&lt;span class="c"&gt;# same CLI, routed&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="n"&gt;claude-9router&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;--resume&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="c"&gt;# normal Claude CLI arguments pass through&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;claude-9router&lt;/code&gt; sets the variables inside its own process, unsets &lt;code&gt;ANTHROPIC_API_KEY&lt;/code&gt; so a leftover key cannot win over the router token, then execs &lt;code&gt;claude&lt;/code&gt;. Close the window and the routing is gone. Nothing in &lt;code&gt;~/.claude&lt;/code&gt; is rewritten.&lt;/p&gt;

&lt;p&gt;That asymmetry is deliberate and it is the actual design decision: the terminal gets process scope because a shell is cheap to start, the panel gets machine scope with an explicit on/off because the extension gives no narrower hook. Cost of the second: you cannot have one routed window and one direct window at the same time. If you need both, use the panel for one and &lt;code&gt;claude-9router&lt;/code&gt; in a terminal for the other.&lt;/p&gt;

&lt;h2&gt;
  
  
  Configuration and what it does not do
&lt;/h2&gt;

&lt;p&gt;The endpoint is a &lt;a href="https://github.com/decolua/9router" rel="noopener noreferrer"&gt;9Router&lt;/a&gt; instance, whose Anthropic-compatible &lt;code&gt;/v1/messages&lt;/code&gt; fronts a long list of providers. The toolkit hard-codes none of them; it reads a gitignored local file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"baseUrl"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"http://127.0.0.1:20128"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"authToken"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"your-9router-api-key"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"mainModel"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"provider/model-id"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"smallFastModel"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"provider/smaller-model-id"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Limits, stated plainly:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Windows only.&lt;/strong&gt; PowerShell 5.1+, and the VSCode settings path is the Windows one. macOS and Linux are not supported today.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Routing the client does not make the model Claude.&lt;/strong&gt; The Claude Code UI is a shell; the answers come from whatever provider you selected in 9Router. Tool use, streaming, vision and prompt caching vary by provider — test the exact model before trusting it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Provider terms are yours to check.&lt;/strong&gt; Some subscriptions and OAuth sessions are not licensed for proxy or router use, and accounts do get restricted for it. Route only accounts you are authorised to route.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No benchmark numbers here&lt;/strong&gt; because I have not measured any. The repo does not claim latency or cost figures either.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Repo, and where a second pair of hands helps
&lt;/h2&gt;

&lt;p&gt;Code, tests and setup guide: &lt;strong&gt;&lt;a href="https://github.com/vinhnguyenthanhdn/claude-router" rel="noopener noreferrer"&gt;https://github.com/vinhnguyenthanhdn/claude-router&lt;/a&gt;&lt;/strong&gt; (MIT).&lt;/p&gt;

&lt;p&gt;The open gap is the platform one: the Windows paths are the only ones implemented and tested, and porting is work I have not done. Three issues are scoped and open:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://github.com/vinhnguyenthanhdn/claude-router/issues/9" rel="noopener noreferrer"&gt;#9 — a macOS terminal launcher at parity with &lt;code&gt;claude-9router.ps1&lt;/code&gt;&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/vinhnguyenthanhdn/claude-router/issues/10" rel="noopener noreferrer"&gt;#10 — macOS support in the VSCode switch (settings path + shell entry)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/vinhnguyenthanhdn/claude-router/issues/6" rel="noopener noreferrer"&gt;#6 — a Linux launcher and VSCode path support&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you only want to confirm the diagnosis rather than write code: run the &lt;code&gt;jq&lt;/code&gt; snippet above on your machine and tell me what your panel is actually inheriting. That answer is useful on its own.&lt;/p&gt;

</description>
      <category>vscode</category>
      <category>powershell</category>
      <category>ai</category>
      <category>opensource</category>
    </item>
    <item>
      <title>How I verify a crypto backtest is real: trade-for-trade parity, bit-for-bit data, and a look-ahead test with teeth</title>
      <dc:creator>Vinh Nguyen</dc:creator>
      <pubDate>Sun, 09 Aug 2026 03:30:16 +0000</pubDate>
      <link>https://dev.to/vinhnguyenthanhdn/how-i-verify-a-crypto-backtest-is-real-trade-for-trade-parity-bit-for-bit-data-and-a-look-ahead-i5j</link>
      <guid>https://dev.to/vinhnguyenthanhdn/how-i-verify-a-crypto-backtest-is-real-trade-for-trade-parity-bit-for-bit-data-and-a-look-ahead-i5j</guid>
      <description>&lt;p&gt;Most public trading repos show you a backtest with a nice equity curve. The hard part isn't producing the curve — it's knowing whether the curve is real. So I spent most of my time building the machinery that decides that, and I've open-sourced it: &lt;a href="https://github.com/vinhnguyenthanhdn/ai-crypto/" rel="noopener noreferrer"&gt;github.com/vinhnguyenthanhdn/ai-crypto&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Python 3.10+, MIT. No real orders are ever placed; every execution path is simulated.&lt;/p&gt;

&lt;h2&gt;
  
  
  Implementation parity, verified trade-for-trade
&lt;/h2&gt;

&lt;p&gt;The single biggest source of false confidence in a backtest is that the thing you tested and the thing you run are not the same program.&lt;/p&gt;

&lt;p&gt;So the research reference engines and the production strategy cores are written &lt;strong&gt;independently&lt;/strong&gt;, then reconciled against each other: &lt;strong&gt;963/963, 515/515 and 1,079/1,079 trades matched with zero mismatches&lt;/strong&gt;, with equity agreeing to 8 decimal places.&lt;/p&gt;

&lt;p&gt;That reconciliation runs as a check, not as a one-off claim.&lt;/p&gt;

&lt;h2&gt;
  
  
  Data verified bit-for-bit
&lt;/h2&gt;

&lt;p&gt;9 years of market history: 942,025 5-minute candles plus 1,508 checksum-verified derivatives archives. Every artifact records a SHA-256 dataset hash, so any result can be tied back to the exact bytes it was computed from.&lt;/p&gt;

&lt;p&gt;The price cache was reconciled against the venue API across &lt;strong&gt;3,277 days and matched bit-for-bit&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  A look-ahead test that provably has teeth
&lt;/h2&gt;

&lt;p&gt;It's easy to write a look-ahead check that passes because it can't detect anything.&lt;/p&gt;

&lt;p&gt;So I deliberately injected look-ahead into a passing contract and measured what happened: training return inflated from &lt;strong&gt;+563% to +4,201%&lt;/strong&gt;. The test can see the bug when the bug is there. That's the part most look-ahead checks never demonstrate.&lt;/p&gt;

&lt;p&gt;Costs are verified the same way — measured at −0.29985% on a flat round trip against a stated 0.30%, confirming they're charged exactly once.&lt;/p&gt;

&lt;h2&gt;
  
  
  Accelerated paper replay through the real lifecycle
&lt;/h2&gt;

&lt;p&gt;3,277 days of history driven through the &lt;strong&gt;production&lt;/strong&gt; SQLite state store, position sizing and accounting. Only the market clock is simulated — everything else is the code that runs live.&lt;/p&gt;

&lt;p&gt;Result: 77 ENTRY / 77 EXIT / 77 ledger rows, zero orphaned entries, zero positions left open.&lt;/p&gt;

&lt;h2&gt;
  
  
  Event-sourced state with full lineage
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Append-only &lt;code&gt;event_log&lt;/code&gt; and &lt;code&gt;feature_snapshot&lt;/code&gt;, plus an idempotent &lt;code&gt;equity_ledger&lt;/code&gt; keyed by trade ID.&lt;/li&gt;
&lt;li&gt;Position and risk computed from &lt;strong&gt;real equity&lt;/strong&gt;, not notional assumptions.&lt;/li&gt;
&lt;li&gt;Every feature snapshot records source exchange, market type, symbol, timeframe, transformation version and the strategy package that consumed it — so any logged decision can be reconstructed from raw candle to logged trade.&lt;/li&gt;
&lt;li&gt;Atomic run locking via SQLite &lt;code&gt;BEGIN IMMEDIATE&lt;/code&gt; with owner token and heartbeat; a dead process's lease is reclaimed immediately instead of waiting out a stale timeout.&lt;/li&gt;
&lt;li&gt;Market-data freshness is enforced: stale collector ticks hard-fail or force a fresh fetch rather than silently reusing an old snapshot.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Costs measured instead of assumed
&lt;/h2&gt;

&lt;p&gt;I sampled 4,695 order book snapshots and pulled base-tier venue fees from official sources. The real half-spread came out at &lt;strong&gt;0.0077 bps&lt;/strong&gt;, not the 5 bps my own config had assumed — my round-trip cost assumption had been about &lt;strong&gt;3× too high&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Worth knowing if you're calibrating a cost hurdle from a blog post rather than from the book.&lt;/p&gt;

&lt;h2&gt;
  
  
  Frozen contracts and a documented search
&lt;/h2&gt;

&lt;p&gt;Parameters are selected on the training split only, the contract is frozen, then validation and test are opened once. Every candidate must survive round-trip costs stressed to at least 2× base.&lt;/p&gt;

&lt;p&gt;Under that protocol I ran &lt;strong&gt;29,373 parameter configurations across ~40 strategy families&lt;/strong&gt;, and every one of them is written up — contract, dataset range, cost assumptions, verdict — as ~100 result artifacts. Including the rejections, which is the part that's usually missing and the part that saves you re-walking a dead end.&lt;/p&gt;

&lt;h2&gt;
  
  
  The repo also audits itself
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;docs/code-audit.md&lt;/code&gt; splits the codebase into a &lt;strong&gt;research path&lt;/strong&gt; that is independently verified and trusted, and a &lt;strong&gt;runtime path&lt;/strong&gt; with confirmed defects — including a BUY threshold that is arithmetically unreachable and a look-ahead bug in the swing detector. Those are catalogued with file and line references rather than quietly patched, so you know which numbers to trust.&lt;/p&gt;

&lt;p&gt;The same honesty applies to the headline outcome: none of the 29,373 configurations cleared the promotion gates. Median gross edge across 13,654 rejected configs is −0.021%, so it isn't a cost problem — there's nothing there to rescue. The full evidence is in the repo, and I'd rather publish that than a curve I can't defend.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python3 &lt;span class="nt"&gt;-m&lt;/span&gt; venv .venv &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;source&lt;/span&gt; .venv/bin/activate
pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; requirements.txt
&lt;span class="nb"&gt;cp&lt;/span&gt; .env.example .env

.venv/bin/python3 scripts/run_backtest.py        &lt;span class="c"&gt;# bar-close scan&lt;/span&gt;
.venv/bin/python3 scripts/dashboard_server.py    &lt;span class="c"&gt;# local dashboard&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;API keys are optional and must be read-only — the system works on public market data alone.&lt;/p&gt;

&lt;p&gt;Contributions welcome, and if you find a hole in the methodology I'd genuinely like to hear it.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/vinhnguyenthanhdn/ai-crypto/" rel="noopener noreferrer"&gt;github.com/vinhnguyenthanhdn/ai-crypto&lt;/a&gt;&lt;/p&gt;

</description>
      <category>python</category>
      <category>opensource</category>
      <category>datascience</category>
    </item>
  </channel>
</rss>
