Repository navigation
[bug] The runner's "don't touch an agent pinned to another machine" check is dead code — /v1/instances/my/instances strips config, so every machine attaches every agent #500
Description
The problem
pags up is supposed to leave an agent alone when the user has pinned it to a different machine.
It does not, on any machine, because the endpoint the runner polls does not return the pin.
isEligible reads it from the instance's config:
// packages/cli/src/commands/runner/membership.ts:32-38
const pin = inst.config?.runnerNode;
...
if (pin && pin !== thisNode && !alsoKnownAs.includes(pin)) return false;and declares the field it expects at membership.ts:17:
config?: { runnerNode?: string | null } | null;The discovery poll's only source is GET /v1/instances/my/instances (relay.ts:146-150), and
that route strips config before responding:
// workers/api/src/routes/instances.ts:304
const { config, instance_config, last_activity_at, ...rest } = r;config is destructured out and never re-added; nothing puts runnerNode back on the response by
another name.
Measured (owner's account, GET /v1/instances/my/instances, 2026-08-11) — the keys returned
for an instance that IS pinned:
['agent_id', 'capabilities', 'category', 'created_at', 'description',
'icon', 'icon_bg', 'id', 'lastActivityAt', 'name', 'slug', 'status']
has config? False runnerNode anywhere in the object? False
So pin is undefined on every instance, on every machine, always. The third eligibility
condition — the only one that is not a copy of up.ts's startup filter — is dead code in
production.
Why it matters
The comment above it states the harm precisely, and it is the harm being taken:
membership.ts:22-27— "an instance pinned to a different node belongs to that machine.
Attaching it here would either lose a race with the pinned machine or, worse, win one and
silently relocate the user's agent away from where they pinned it."
Live, on the owner's account (GET /v1/terminals/nodes, 2026-08-11): Sergeys-Mac-mini.local
holds live relay sockets for all 18 of its instances, including iTerm2 Operator, kitty Operator, Terminal Operator and tmux Operator, which are pinned to RLs-MacBook-Air.local
(bound: true under the Air's machine group, which folds its Mac / RLs-MacBook-Air aliases).
Routing then resolves whichever socket happens to be live, which is not necessarily the machine
the user chose.
It also adds avoidable relay churn on every machine: each runner opens and holds sockets for the
entire account rather than its own share, which is the population that the 4409 problem in #497
then acts on.
The startup path has the same hole, differently
up.ts:73-85 filters on status === "active" and capabilities.runtime != null only — there is
no pin check at startup at all, by design ("Two conditions come straight from up.ts's startup
filter, so discovery and startup can never disagree", membership.ts:21). The intent was that
discovery would detach a wrongly-attached instance within 20s. With the pin invisible, neither
half runs.
Fix
Server-side, so every already-installed CLI is fixed with no release (the owner is on 0.4.45;
the shipped code already reads inst.config?.runnerNode): re-add a minimal config object to the
list response — config: { runnerNode } and nothing else, parsed with the existing
parseBoundRunnerNode. Do not return config wholesale: it is dropped deliberately because
it "may hold secrets/internal settings" (instances.ts:301-303), and that reason is still good.
Additive and optional, so no console change is required; nothing in store/console/src reads
config off this response today.
Rejected: adding a new top-level runnerNode field and teaching the CLI to read it. Cleaner
shape, but it needs a CLI release and leaves every deployed runner broken until users upgrade —
for zero behavioural gain over the shape the existing code already parses. If a nicer shape is
wanted later, ship both and drop the nested one after a version floor.
The test could not have caught this
// packages/cli/src/commands/runner/membership.test.ts:27-28
expect(isEligible(inst({ id: "a", config: { runnerNode: "other-box" } }), NODE)).toBe(false);
expect(isEligible(inst({ id: "a", config: { runnerNode: NODE } }), NODE)).toBe(true);Both assertions pass and both feed the pure function an input shape the server has never sent.
Same blind spot as #438 — nothing verifies a parameter surviving the trip route → client.
Acceptance criteria
-
GET /v1/instances/my/instancesreturnsconfig: { runnerNode }for a pinned instance and
omits or nulls it otherwise; no other config key appears in the response. - A route-level test asserts the field is present for a pinned instance and that no other
config key leaks — the assertion that makes the CLI's unit test mean something. - With two machines running
pags up, an instance pinned to machine A has a live relay socket
on A only; machine B detaches it within one discovery poll.
Regression risk
- Re-adding any part of
configto a list response is exactly how a secret leaks. Pin the test to
"these keys and no others", not "contains runnerNode". - Machines will now detach instances they currently serve. If a pin names a hostname the pinned
machine no longer uses,alsoKnownAs(machine.names) is what keeps it attached — that path is
[bug] A machine's identity is os.hostname(), so one laptop is three machines — and a pin to a name it no longer uses answers every call with "run pags up" while pags up is running #379/[enhancement] Recovering from a hostname change means hand-editing machine.json — the CLI should ask, on first run, which of the account's unclaimed names are this machine #460 and it must be exercised here, or this fix re-creates the bug [bug] A machine's identity is os.hostname(), so one laptop is three machines — and a pin to a name it no longer uses answers every call with "run pags up" while pags up is running #379 fixed: the poll
detaching the very agent the user pinned to this laptop, 20s after startup.
Related
- [bug]
pags updoes not self-heal after a wake: registration never rides the reconnect path, and the machine is locked out of its own relay slot by the socket it left behind #497 —pags uprecovery after a wake; this is the same discovery loop, different defect. - [bug] A machine's identity is os.hostname(), so one laptop is three machines — and a pin to a name it no longer uses answers every call with "run pags up" while pags up is running #379 / [enhancement] Recovering from a hostname change means hand-editing machine.json — the CLI should ask, on first run, which of the account's unclaimed names are this machine #460 / [bug] A wrong machine claim cannot be undone — nothing un-stamps machine_id, and a server-only un-claim would be re-stamped by the next
pags up#467 — machine identity and name claiming; supply thealsoKnownAshalf. - Both testing blind spots are closed — what remains is a fetch-deadline floor in safeFetch, then two ratchets (deadline + responsive-label) #438 — no test verifies a parameter crossing client → route → DO.
Activity
fix(runner): the instance list carries the runner-node pin, so a mach…
serge-ivo commentedon Aug 11, 2026
Fixed in
e18b29e(onmain, API worker deployed).What shipped.
GET /v1/instances/my/instancesnow carriesconfig: { runnerNode }for apinned instance and nothing else, via
instanceListViewinworkers/api/src/lib/instance-config.ts— a whitelist, not the blob minus a denylist, so a config key added later has to be named to
travel. Server-side as the issue specified, so every already-installed CLI (the owner is on
0.4.45) is fixed with no release.
Verified live, against
api.proagentstore.onlineafter the deploy:13 pinned instances,
runnerNodeand no other key, and noconfigkey at all on the other 16.Acceptance criteria
config: { runnerNode }for a pinned instance, omits it otherwise, no other configkey appears — verified live above.
config carrying
specialInstructionsand asettings.token, then assertsObject.keys(config) === ["runnerNode"]and that neither secret appears anywhere in theresponse body. This is the assertion the CLI's unit test could not be (Both testing blind spots are closed — what remains is a fetch-deadline floor in safeFetch, then two ratchets (deadline + responsive-label) #438's blind spot).
RLs-MacBook-Air.local sockets live: 6, pinned elsewhere: 0/Sergeys-Mac-mini.local sockets live: 8, pinned elsewhere: 0.The
alsoKnownAsrisk you flagged was real and is covered. Five of the thirteen pins nameMac, not the current hostname.~/.config/proagentstore/machine.jsonon the Air holdsnames: ["RLs-MacBook-Air.local", "Mac", "RLs-MacBook-Air"], soisEligible'salsoKnownAscheck keeps them attached there — checked on the machine, not assumed. Without that file this
change would have detached them, which is exactly the #379 bug.
Not done, deliberately: the startup filter in
up.tsstill has no pin check. The issue notesthis is by design (discovery detaches within 20s) and its stated fix is server-side only; adding a
second pin check at startup is a separate decision, not silently folded in here.
Not reproduced end to end: the two-machine detach-within-one-poll behaviour is asserted through
diffMembership's unit tests and the live invariant above, not by driving two laptops.serge-ivo commentedon Aug 11, 2026
Delivered and verified in production — see the comment above.
fix(agents): a runner-too-old refusal names the machine, its version,…