Bash applies redirections from left to right. If opening an input file fails before stderr is redirected, Bash writes the open error to the original stderr.
read -r value < "$file" 2>/dev/null # Input open happens first
read -r value 2>/dev/null < "$file" # Stderr is redirected first
When a file may be missing and you want to hide Bash's open error, place 2>/dev/null before the input redirection. This changes where the diagnostic goes, not the command's exit status. It is not a universal “all output redirects first” rule; redirections run left to right, so their order determines which destination is active when each one is applied.
If you host anything customer-controlled under your own domain (say alice.app.example.com) and run your sign-in or billing site on another subdomain (billing.example.com), those customers can set cookies on your billing site. A cookie with Domain=example.com goes to every host under example.com. Signing your cookies doesn't help, because the attacker doesn't need to forge anything.
The attack is login CSRF by "cookie tossing". The attacker signs in to your site and copies their own valid, signed session cookie. A page on their subdomain sends:
Set-Cookie: session=<attacker's valid value>; Domain=example.com; Path=/
A victim opens that page, then visits your billing site. The browser sends the attacker's cookie, so the victim is signed in as the attacker, and whatever they buy is credited to the attacker's account. Having their own session doesn't reliably protect them either: the browser sends both cookies with the same name, and many parsers keep whichever comes last.
Picking a different hostname doesn't help while both hosts share the same registrable domain. The fix is one prefix on the cookie name:
Set-Cookie: __Host-session=<value>; Path=/; Secure; HttpOnly; SameSite=Lax
Browsers only accept a __Host- cookie that has Secure, has Path=/, and has no Domain attribute. It can only be set by the exact host that receives it, so a sibling subdomain can't create or overwrite it. Read and clear the cookie under the same prefixed name. Since the prefix needs Secure, keep an unprefixed name for plain-http local development.
The stronger, structural fix is to serve customer content from a separate registrable domain, the way GitHub uses githubusercontent.com, or to get that domain listed on the Public Suffix List. Either way, cookies stop crossing the boundary at all. __Host- is the change you can ship today.
我有一个手动发推的 CLI(Playwright 驱动登录状态的 Firefox 网页,所以不需要 X API key),还有一个多用户笔记应用(HappyNotes)把新笔记推入 Redis 队列同步到 Mastodon。想让它俩连起来:笔记一发,自动发推。但不想动共享后端、毕竟这是一个很私人的hack,也没法子支持其他用户。
演进三步
1. CLI → 微服务。 把发推核心抽成库,Node 内置 http 起一个服务,只监听 Tailscale IP(100.x.y.z:8090)——内网即鉴权,零依赖、零鉴权代码。任何 tailnet 机器都能入队:
curl -X POST http://100.x.y.z:8090/add \
-H 'content-type: application/json' \
-d '{"text":"hello","post_at":"2026-09-07T09:00:00+12:00"}'
2. 图片走 data URL。 别的机器没有你这台的文件系统,multipart 又要新依赖——所以图片在 JSON body 里以 data:image/png;base64,... 传输,Playwright setInputFiles 喂内存 buffer,最多 4 张。
3. 只读 Redis 旁观者。 这是关键设计:不 LPOP(会偷走 Mastodon 消费者的消息),只每秒 LRANGE queue + ZRANGEBYSCORE processing,用 redis-cli 子进程(还是零新依赖)。然后三道过滤:
userId == OWNER_USER_ID && action == "CREATE" && isPrivate == false
task.id 落盘去重(先写 seen 再发布,失败绝不重试——重试 = 重复推文),冷启动基线首轮只记已见不发布,防止积压刷屏。
代价:一个有意的竞态窗口
队列项最多活 5 秒(消费者 LPOP 后成功后 ZREM,删了就没了,没有 deleted 队列可拣漏)。所以旁观者"几乎不漏但不保证"——如果任务在两次轮询之间被消费完,就错过了。要修复的话要动HappyNotes后端(加一个 synced 队列,成功时 LPUSH + 1 小时 TTL),但只为我一个人需求改全站代码不太值,先接受这个妥协看看。
Agent system prompts rot the same way every time: each fix tacks on a sentence, nothing ever comes off, and the prompt grows forever. Two things break as a result — the prompt drifts past whatever size budget you have, and (the subtle one) the prompt's prose is the agent's prose. A verbose, redundant constitution teaches a verbose, redundant writing voice.
So make every addition carry a matching subtraction. When you add a rule, find the existing rule it makes redundant and cut it. In practice the new rule almost always overlaps something — a preamble about "only ask when a decision genuinely needs the human" makes a stale "no confirmation requests for obvious next steps" bullet redundant; fold one into the other.
The part people skip: don't eyeball whether the wording "fits" — measure it. Render the prompt with all includes expanded, byte-count it, and compare against a budget:
# render each role's full prompt and check it against a per-role ceiling
for src in agents/*.md; do
role=$(basename "$src" .md)
_mat_render_prompt "$src" "/tmp/$role.md"
size=$(wc -c < "/tmp/$role.md")
ceiling=$(jq -r --arg r "$role" '.[$r]' ceilings.json)
echo "$role: $size / $ceiling (headroom $((ceiling-size)))"
done
A checked-in per-role ceiling turns this into a ratchet: adding a line either fits under existing headroom, or forces you to bump the ceiling in the same diff — which makes the growth visible and reviewable instead of silent.
Measuring mattered more than expected. The fattest candidate wording netted +161 bytes; the tightest role had exactly 163 bytes of headroom. Two bytes of slack — and a longer {{USERNAME}} at render time would have blown it. Invisible by eye, obvious once counted. We trimmed the wording to net +135 and left every ceiling untouched.
And run the ratchet both ways. When you remove fat, lower the ceiling to the new size × 1.10 in the same change — otherwise the slack you just created quietly refills.
In development, your frontend runs on localhost:5173 and your API server on localhost:3000. The browser blocks cross-origin requests — that's CORS. Vite's dev proxy solves this by forwarding /api/* requests to the backend, making them look same-origin to the browser:
// vite.config.ts
server: {
proxy: {
'/api': 'http://localhost:3000'
}
}
In production this proxy disappears. The built frontend is just static files (HTML/JS/CSS) — no port, no process. Nginx or a CDN serves them, and reverse-proxies /api/* to the backend the same way Vite did in dev:
user → Nginx :80
├── /api/* → backend :3000
└── /* → dist/ static files
One port from the user's perspective, no CORS issue. The backend port is always real and needed; the frontend "port" only exists during development because Vite's dev server is a live process.