Skip to content

fix(remote): stop persisting the daemon bearer token, and authenticate forced-reconnect release correctly - #1648

Open
thymikee wants to merge 4 commits into
mainfrom
fix/remote-connection-token-strip
Open

fix(remote): stop persisting the daemon bearer token, and authenticate forced-reconnect release correctly#1648
thymikee wants to merge 4 commits into
mainfrom
fix/remote-connection-token-strip

Conversation

@thymikee

@thymikee thymikee commented Aug 6, 2026

Copy link
Copy Markdown
Member

Summary

Two commits: an ADR-0007 conformance fix, and the P1 regression it exposed (caught in review of #1639 — thanks).

1. Stop persisting the daemon bearer token (7ce71723f) — BREAKING

ADR 0007 states that generated connection profiles "must strip daemon and Metro bearer tokens". The Metro half was honoured; the daemon half was not — connect wrote the token into the connection-state file and every later command read it back. The same file's sanitizeDaemonBaseUrl already strips credentials out of the base URL, so the intent was there; the token just sat beside it as a field.

After this, a token passed once to connect is not reused by later commands:

# before
agent-device connect proxy --daemon-base-url <url> --daemon-auth-token <token>
agent-device devices            # worked — token came back off disk

# after
export AGENT_DEVICE_DAEMON_AUTH_TOKEN=<token>
agent-device connect proxy --daemon-base-url <url>
agent-device devices            # resolves from env / config / flag

Every alternative path ADR 0007 names already existed (AGENT_DEVICE_DAEMON_AUTH_TOKEN, the daemonAuthToken config key, --daemon-auth-token), so this is conformance rather than new machinery. website/docs/docs/remote-proxy.md is updated.

2. Authenticate a forced reconnect against the endpoint it is actually releasing (f0586db76)

Commit 1 removed the persisted token and switched both release call sites to the caller's flags.daemonAuthToken. One of those two was wrong.

disconnect releases the current connection, so the ambient token belongs there. But cleanupForcedPreviousConnection releases the previous one — previous.leaseId, previous.daemon.baseUrl, previous.tenant all come off previous — while the token came off the new connection's flags. So replacing profile A with a differently-authenticated B sent B's token to A's endpoint, and releasePreviousLease's bare catch {} swallowed the auth failure: the reconnect reported success and A's lease plus provider-side resources were orphaned, silently. The persisted token had been masking the distinction, which is why it only surfaced once commit 1 removed it.

Now resolved for the endpoint being released: the previous connection's own profile token first; the ambient token only when previous and next base URLs match; otherwise the new credential is not sent to the old endpoint and an actionable notice naming tenant, run id, lease id and previous base URL is surfaced instead. releasePreviousLease no longer swallows failures. Reconnect still succeeds — this is a notice on the success path, reusing the existing RuntimePreparationNotice/LeasePreparationNotice channel rather than a new one.

Validation

The P1 regression pin proven red — resolvePreviousLeaseAuth forced back to the ambient token, then:

FAIL src/__tests__/remote-connection.test.ts > connect --force releases the previous lease
     with the previous connection's own token, not the new one
AssertionError: Expected values to be strictly equal:
+ 'test-new-not-a-real-token'
- 'test-old-not-a-real-token'

Three new tests cover the rule's three cases: old-profile token used over the new one; no recoverable old token with a differing endpoint (no release attempted, notice surfaced); same endpoint falling back to the ambient token so ordinary same-profile --force keeps working.

Two pre-existing --force tests were strengthened, not relaxed: they previously only asserted that a release happened, and now assert which token was sent (assert.equal(releaseRequest?.daemonAuthToken, 'test-old-…') plus an explicit notEqual against the new one) against lease-old at https://old.example. The only removed fixture line was the old config's tokenless form. All token literals are obviously fake.

isRemoteConnectionState/isRemoteConnectionDaemonState were checked for strictness (remote-connection-state.ts:294, :333): they tolerate unknown keys, so a stale on-disk state file carrying authToken still parses and the field is simply never read. No migration needed.

Scope

Not device-facing, so no simulator/emulator evidence applies. Split out of #1639 per review. Local gates are currently unreliable on this machine due to a competing test run — pushed on GitHub CI's authority with maintainer agreement.

@github-actions

github-actions Bot commented Aug 6, 2026

Copy link
Copy Markdown
PR Preview Action v1.8.1

QR code for preview link

🚀 View preview at
https://callstack.github.io/agent-device/pr-preview/pr-1648/

Built to branch gh-pages at 2026-08-06 15:25 UTC.
Preview will be ready when the GitHub Pages deployment is complete.

@github-actions

github-actions Bot commented Aug 6, 2026

Copy link
Copy Markdown

Size Report

Metric Base Current Diff
JS raw 1.99 MB 1.99 MB +1.4 kB
JS gzip 635.8 kB 636.2 kB +411 B
npm tarball 769.2 kB 769.7 kB +418 B
npm unpacked 2.69 MB 2.69 MB +1.4 kB

Startup median (7 runs, lower is better):

Scenario Base Current Diff
CLI --version 26.2 ms 26.5 ms +0.3 ms
CLI --help 64.5 ms 64.2 ms -0.3 ms

Top changed chunks:

Chunk Raw diff Gzip diff
dist/src/cli.js +1.4 kB +408 B

@thymikee

thymikee commented Aug 6, 2026

Copy link
Copy Markdown
Member Author

Reviewed exact head f0586db. One P1 remains in the forced-reconnect credential fix: resolvePreviousOwnDaemonAuthToken calls resolveRemoteConfigProfile with process.env before checking endpoint equality. That resolver merges AGENT_DEVICE_DAEMON_AUTH_TOKEN into the loaded profile, so when old profile A has no file token and new endpoint B is authenticated through the environment, B’s token is misclassified as A’s own token and sent to A during release. This recreates the credential leak/orphaned-lease path. The current unreleasable-old-token test leaves env empty and supplies B through flags, so it misses the production env path. Load the previous profile without ambient credential defaults (or otherwise preserve source provenance), and add a tokenless-A/different-endpoint/env-token-B regression proving no release request sends B to A. CI also still has substantive jobs pending. No readiness label.

ADR 0007 requires generated connection profiles to strip daemon and Metro
bearer tokens; only the Metro half was honored. `connect` was writing the
daemon bearer token into the 0600 connection-state file, and every later
command read it back out.

Stop writing `authToken` into `RemoteConnectionState['daemon']` and resolve
it at each reader from the existing flag -> environment
(AGENT_DEVICE_DAEMON_AUTH_TOKEN) -> remote-config-profile chain instead,
matching src/cli/auth-session.ts's precedence.

Behavior change: a user who ran `connect --daemon-auth-token <value>` and
relied on later commands picking the token back up from the state file will
now get an auth failure. They must export AGENT_DEVICE_DAEMON_AUTH_TOKEN,
set daemonAuthToken in their remote config, or pass --daemon-auth-token on
each command. website/docs/docs/remote-proxy.md is updated to show the
supported env-var workflow.
…vious endpoint's own credential

connect --force released the previous connection's lease using the new
connection's ambient daemonAuthToken instead of the previous endpoint's own
credential, and swallowed the resulting auth failure — silently orphaning the
old lease when replacing a connection with a differently-authenticated one.

Resolve the release token from the previous connection's own remote-config
profile first, fall back to the ambient token only when the two connections
share the same daemon endpoint, and otherwise skip the release and surface an
actionable notice (tenant, run id, lease id, endpoint) through the existing
connect notice channel instead of hiding the failure.
…e's own token

resolvePreviousOwnDaemonAuthToken read the previous connection's profile
through resolveRemoteConfigProfile, which folds AGENT_DEVICE_DAEMON_AUTH_TOKEN
(and other env defaults) into the result. When the previous config file
declared no token and the new connection's credential came from that same
global env var, it was misclassified as belonging to the previous endpoint
and sent there on forced-reconnect release — recreating the credential leak
the prior fix was meant to close, just via env instead of --daemon-auth-token.

Read the previous profile with the new readRemoteConfigFile (a provenance-
preserving, file-only load with no ambient env/CLI merging), so only a token
the previous config file itself declares can satisfy rule 1. Rules 2 and 3
are unchanged.
@thymikee
thymikee force-pushed the fix/remote-connection-token-strip branch from f0586db to d79bf78 Compare August 6, 2026 15:22
@thymikee

thymikee commented Aug 6, 2026

Copy link
Copy Markdown
Member Author

Fixed at d79bf787f. Your diagnosis was exact, and the fix goes to the layer you pointed at rather than post-filtering the merged result — as you implied, once the file and the environment yield the same string, a post-filter cannot tell them apart.

Root cause confirmed: resolveRemoteConfigProfile merges readRemoteConfigEnvDefaults(env) into the loaded profile. Rule 1 is supposed to mean "a token that provably belongs to endpoint A because A's own config file declared it" — reading it through that resolver meant an ambient AGENT_DEVICE_DAEMON_AUTH_TOKEN could satisfy it, and the env var carries no endpoint provenance at all.

resolvePreviousOwnDaemonAuthToken now calls readRemoteConfigFile — the file-only parse resolveRemoteConfigProfile itself performs before the env merge, exported from remote-config-core.ts for this. It still receives env/cwd, but only for config-path resolution (~ expansion); daemonAuthToken is not a path-type field, so no credential provenance flows through it. Rules 2 and 3 are byte-for-byte unchanged — the environment fallback remains rule 2's job, gated on matching endpoints.

Red run for the new regression (env-merging read restored, tokenless profile A, differing endpoints, B supplied through AGENT_DEVICE_DAEMON_AUTH_TOKEN):

FAIL src/__tests__/remote-connection.test.ts > connect --force does not misclassify
     an env-sourced new token as the previous connection's own credential
AssertionError: Expected values to be strictly equal:
+ {
+   daemonAuthToken: 'test-env-not-a-real-token',
+   daemonBaseUrl: 'https://old.example',
+   leaseId: 'lease-old',
+   tenant: 'acme'
+ }
- undefined

That is the leak itself rather than a proxy for it: a release request was issued, carrying the env-sourced new token against the old endpoint. After the fix no request is issued and the unreleasable-lease notice surfaces instead.

You were also right about why the existing coverage missed it — the old unreleasable-old-token test supplied B through flags with an empty env, so it never took the production environment path. The new fourth test is the only one that does; the other three were re-checked and still pin what they claim (test 1 uses a file-declared token and never depended on the merge; tests 2 and 3 involve no env var).

pnpm check:affected --run green; rebased onto current main.

@thymikee

thymikee commented Aug 6, 2026

Copy link
Copy Markdown
Member Author

Re-reviewed exact head d79bf787. The ambient-env leak is fixed, but one P1 cross-endpoint credential path remains.

P1 — the previous config file is trusted after it may have changed identity. resolvePreviousOwnDaemonAuthToken re-reads previous.remoteConfigPath and accepts its current token without checking the state’s saved previous.remoteConfigHash or verifying the file’s current daemonBaseUrl still matches previous.daemon.baseUrl. Common repro: connect to endpoint A from path P; edit/reuse P for endpoint B with token B; run connect --force. The function now calls the file-only reader, but it still classifies B as A’s “own” credential and sends B to old endpoint A during lease release.

Validate the old file’s saved hash/endpoint provenance before trusting its token (or use an equivalent endpoint-bound source), and add a same-path A→B regression proving no release request sends B to A. The current test uses distinct immutable old/new paths, so it cannot catch this. Fallow and FreeRange both failed before checkout on GitHub action-download 500/503 outages; all other substantive checks are green.

…point

Rule 1 reads the previous connection's own config file to recover a credential
that provably belongs to the previous endpoint. It re-read
`previous.remoteConfigPath` and trusted whatever token that file holds *now* —
but a config path is routinely reused, so "connect to A from ./remote.json,
re-point ./remote.json at B, connect --force" classified B's token as A's own
and sent it to A during lease release. Same cross-endpoint leak the env-merge
fix closed, arriving through the file instead of the environment.

The file must now still vouch for the previous endpoint, by either of two
independent facts: its bytes still hash to the `remoteConfigHash` recorded at
connect time (so it is literally the declaration that stood up the previous
connection), or — if it changed — it still declares the same daemon base URL.
The second is what keeps an ordinary credential rotation releasing its lease
instead of orphaning one; endpoint equality, not the fact of an edit, is what
separates rotation from re-pointing.

Endpoint comparison runs both sides through `buildRemoteConnectionDaemonState`,
the same normalizer that produced the stored `daemon.baseUrl`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Rva4YGtSCAKJqH5PbpcCU
@thymikee

thymikee commented Aug 6, 2026

Copy link
Copy Markdown
Member Author

Re-reviewed exact head 5968975. P1 remains: an unchanged remote-config file is trusted by hash even when explicit CLI flags overrode its endpoint and token for the previous connection. Supported repro: profile P declares endpoint B and token B; initial connect uses CLI endpoint A and token A, so state records A; later connect --force re-reads unchanged P, hash-matches it, classifies B as A own credential, and sends token B to endpoint A during lease release. resolveConnectProviderProfile explicitly merges profile flags first and CLI flags second, so hash equality proves file identity, not that its token was the effective credential for the stored endpoint. Require normalized declared endpoint equality even on the hash-match branch, fail closed when the profile declares no endpoint, and add this unchanged-profile CLI-override regression asserting no release request sends B to A. No device evidence applies; this is remote auth and state logic. No readiness label.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants