Posts in category “Tips”

`bg-run` needs a real owner, not a detached process

bg-run sounds like a small shell convenience: start a command and let the agent continue. Its real contract is much larger. The command must survive the end of the current agent turn, record its result, report meaningful milestones, and wake the right session later. That is a lifecycle and delivery problem, not an ampersand problem.

A reliable implementation needs a durable owner, an isolated process group, cancellation and reaping, durable result and event records, and a callback path into the session that started the work. It also needs at-least-once delivery, because a wake-up can be retried without creating a second task.

The tmux implementation works because tmux happens to provide most of this machinery. A helper session owns the process after the caller returns, and a pane gives the result a place to wake. bg-run can write immutable milestone and final events, then the agent can consume them on its next turn. On a tmux host, this is a perfectly useful adapter.

But tmux is a hosting-specific workaround, not the underlying abstraction. A Baton driver has no pane and no TMAT_PANE, so the tmux implementation correctly refuses to run. Replacing it with setsid, disown, or another backgrounding trick would not fix the ownership problem: an external tool runner can still clean up the caller's descendant process tree, leaving a state file that names a dead worker. Detaching a process is not the same as giving it a supervisor.

Baton already has the right foundation. baton serve is a resident mailbox responder with atomic pending/claimed/done delivery, single-instance locking, stale-work reclaim, and cooperative stop. The missing piece is making that residency real for the whole integration: a host-owned baton service process, run in the foreground under something like a systemd user service, should spawn and own each session's baton serve and each asynchronous task. The client that submits work must not be its owner.

On top of that service, a generic baton task start API can return a stable task ID immediately, persist the command specification and state, run the task in its own process group, capture its output, and emit immutable milestone and terminal events to the requested Baton mailbox. task status and task cancel complete the lifecycle; session teardown cancels and reaps its tasks. The agent starts the task, ends its turn, and is woken by the mailbox when there is something worth reading. No sleep 50, PID loop, or result-file polling is needed.

That is why the Baton design is the general solution: it treats ownership and notification as a protocol rather than an accidental property of a terminal multiplexer. bg-run remains a good agent-facing name in my-ai-team because it describes the user action. Baton itself should expose a provider-neutral task or job primitive, with my-ai-team's bg-run as one adapter. Tmux can remain a useful adapter where it exists; Baton supplies the real owner where it does not.

Moving a Guacamole (or any stateful Docker stack) to a new host? Copy the volume, don't re-init

When you move a Guacamole stack between machines, the temptation is to spin up a fresh stack on the new host and let initdb.sql build the database. Don't — that gives you an empty install. Every saved connection, every user, and (critically) every TOTP/MFA enrollment lives inside the MariaDB data volume. Re-running the init script wipes all of it, and your users have to re-scan their authenticator QR codes.

The fix is a cold, byte-exact copy of the DB volume. Stop the stack first so the copy is consistent:

# on the OLD host
cd ~/path/to/guacamole && docker compose down
docker run --rm -v guacamole_db-data:/v -v /tmp:/out alpine \
  tar czf /out/guacdb.tgz --numeric-owner -C /v .

--numeric-owner matters: MariaDB's files are owned by uid 999 inside the container, and you want that uid preserved, not remapped to whatever user happens to exist on the new box.

Ship the tarball over, then restore it into a fresh named volume before the first up:

# on the NEW host
docker volume create guacamole_db-data
docker run --rm -v guacamole_db-data:/v -v /tmp:/in alpine \
  tar xzf /in/guacdb.tgz --numeric-owner -C /v
cd ~/path/to/guacamole && docker compose up -d

MariaDB's entrypoint checks whether the data directory is empty. Since you just populated it, it skips initialization entirely and comes up with all your data intact — confirm with docker logs guacamoledb | grep "ready for connections" and no Initializing database line.

One more thing worth doing while you're at it: if a reverse proxy fronts the app (e.g. nginx terminating TLS on a separate edge box), point its proxy_pass at a DNS name that tracks the new host's IP rather than the raw IP. Then a future move is a one-line edge change — or zero, if the name already follows the host. A quick sanity check that the whole path works, without needing to log in:

curl -s -o /dev/null -w "%{http_code}\n" -X POST https://your.guac.example/api/tokens \
  -H "Content-Type: application/x-www-form-urlencoded" --data "username=x&password=y"

A 403 here is success — it means the webapp reached the database and rejected bad credentials. (A 500 usually just means you forgot the application/x-www-form-urlencoded content type, not that anything's broken.)

`gh` suddenly 401s over SSH after a reboot? Your token is locked in the keyring

After a reboot, every gh command returned HTTP 401: Requires authentication — even though you ran gh auth login ages ago. It looks like "the reboot wiped the auth."

The usual culprit: the token was saved in the system keyring (libsecret / gnome-keyring), and you log in over plain SSH (publickey). On an SSH login PAM never sees your password, so pam_gnome_keyring doesn't unlock the keyring — only a desktop/GUI login does that as a side effect. After a reboot the keyring stays locked, gh can't read the token, and you get a 401. It's not really about the reboot; it's that after the reboot no desktop login ever unlocked the keyring.

On a headless / SSH-only box, don't hand your credentials to the keyring. The simplest fix is an environment variable — gh reads it first and never touches the keyring or hosts.yml:

# ~/.bashrc.secret (sourced by login shells)
export GH_TOKEN=ghp_xxxxxxxxxxxxxxxxxxxx

Verify without printing the token — just check which account it resolves to:

gh api user -q .login      # prints your username on success

GH_TOKEN loads with every login shell, so reboots and pure SSH both stay stable. Clear the now-redundant credential in the keyring / hosts.yml (gh auth logout) to keep a single source of truth.

Broader lesson: on a headless machine, anything that assumes an interactive desktop session — keyring unlocking, a resident user-level systemd service — will bite you. Prefer session-independent mechanisms (environment variables, loginctl enable-linger).

重启后纯 SSH 登录,gh 突然 401?token 可能锁在 keyring 里

机器重启后,gh 命令全线 HTTP 401: Requires authentication,可你明明早就 gh auth login 过。奇怪的是,好像"重启一下授权就没了"。

真凶多半是:token 存进了 系统 keyring(libsecret/gnome-keyring),而你走的是纯 SSH(publickey)登录。SSH 登录时 PAM 拿不到你的登录密码,pam_gnome_keyring 不会解锁 keyring——只有桌面/GUI 登录才会顺带解锁。于是重启后 keyring 一直锁着,gh 读不出 token,直接 401。这跟"重启"本身无关,是"重启后再没有任何桌面登录去解锁 keyring"。

无头/纯 SSH 机器上,别把凭据托付给 keyring。最省心的是用环境变量,gh 优先读它,完全不碰 keyring、也不落 hosts.yml:

# ~/.bashrc.secret (被登录 shell source)
export GH_TOKEN=ghp_xxxxxxxxxxxxxxxxxxxx

验证时别把 token 打出来,看它解析到哪个账号就行:

gh api user -q .login      # 打印用户名即成功

GH_TOKEN 随登录 shell 加载,重启、纯 SSH 都稳。清掉 keyring/hosts.yml 里那份冗余凭据(gh auth logout)可保持单一来源。

更广的教训:无头机器上,任何依赖"交互式桌面会话"的机制——keyring 解锁、user 级 systemd 服务常驻——都会踩坑,优先选不依赖会话的方案(环境变量、loginctl enable-linger)。

Pinning a gh account with `GH_TOKEN="$(gh auth token --user X)"`? Check it's non-empty

A common pattern for scripts that must always hit GitHub as a specific account, regardless of whatever account is currently active, is:

GH_TOKEN="$(gh auth token --user work-account)" gh workflow run ...

This works — until gh auth token --user work-account fails and returns an empty string. GH_TOKEN="" is not treated as "unset" by your shell, but it is treated as unset by gh itself, which then silently falls back to whatever account is currently active in the keyring. If that's your personal account and the target repo belongs to an org it can't see, you get a confusing HTTP 404: Not Found — which reads like a missing-repo problem, not an auth problem.

Guard the lookup instead of trusting it inline:

TOKEN="$(gh auth token --user work-account)"
if [ -z "$TOKEN" ]; then
    echo "Error: could not obtain gh token for work-account" >&2
    exit 1
fi
GH_TOKEN="$TOKEN" gh workflow run ...

Same fix applies to any wrapper that routes gh calls by repo owner: resolve the token first, fail loudly if it's empty, and only then invoke the real gh binary. Silent fallback to the active account is the failure mode to design out.