Skip to content

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

Merged
thymikee merged 5 commits into
mainfrom
fix/remote-connection-token-strip
Aug 6, 2026
Merged

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

Conversation

@thymikee

@thymikee thymikee commented Aug 6, 2026

Copy link
Copy Markdown
Member

Summary

Stop persisting the remote daemon bearer token in connection state, as required by ADR 0007, and make forced reconnect cleanup authenticate only when the credential is demonstrably owned by the endpoint being released.

The forced reconnect path releases the previous lease. It now:

  • resolves a token from the previous profile only when that profile explicitly declares the same normalized endpoint as the stored previous connection;
  • permits the ambient token only when the previous and next endpoints are the same;
  • otherwise skips the release, preserves the reconnect, and surfaces an actionable notice with the previous tenant, run, lease, and endpoint details.

An unchanged profile hash is not sufficient provenance by itself. CLI flags may have overridden the profile endpoint during the previous connection, so the profile's token cannot be sent to that stored endpoint unless the profile also declares that endpoint. This preserves benign same-endpoint token rotation while failing closed across endpoints.

The bearer-token persistence removal is intentionally breaking: later commands must resolve the credential from AGENT_DEVICE_DAEMON_AUTH_TOKEN, daemonAuthToken in the remote config, or --daemon-auth-token. The remote-proxy documentation is updated accordingly.

Validation

The final regression was proven red before the fix: an unchanged profile declaring endpoint B was associated with stored endpoint A after a CLI override, and connect --force sent B's token to A. The production fix removes the hash-only shortcut and requires normalized declared-endpoint equality.

  • Focused remote connection suites: 52/52 passed.
  • pnpm check:affected --run: all runnable checks passed after rebasing onto origin/main.
  • Node integration smoke: 52 passed, 8 device-gated tests skipped.

This path is remote connection state/authentication logic, not device-facing, so simulator or physical-device evidence does not apply.

@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.

@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.

@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.

thymikee commented Aug 6, 2026

Copy link
Copy Markdown
Member Author

Fixed at 596897529. Your repro was exact, and it is the same class of leak as the last one arriving through a different door — the file instead of the environment.

Root cause, confirmed. Rule 1's claim is "this token provably belongs to endpoint A because A's own config file declared it." remoteConfigPath names a file now, while the claim is about what that file declared when the previous connection was established. Nothing was checking that those are the same file. A config path is routinely reused, so re-pointing it at B made B's token read as A's own.

Red run for your exact repro (one path, edited in place between connect and connect --force, provenance check removed):

FAIL src/__tests__/remote-connection.test.ts > connect --force does not treat a re-pointed
     config path's token as the previous endpoint's own
AssertionError: Expected values to be strictly equal:
+ {
+   daemonAuthToken: 'test-new-not-a-real-token',
+   daemonBaseUrl: 'https://old.example',
+   leaseId: 'lease-old',
+   ...
+ }
- undefined

Token B, addressed to old.example. That is the leak itself, not a proxy for it. With the fix no release request is issued at all and the unreleasable-lease notice surfaces instead.

The fix, and the one judgement call in it. The previous file must now still vouch for the previous endpoint, by either of two independent facts:

  1. its bytes still hash to the remoteConfigHash recorded at connect time — then it is literally the declaration that stood up that connection; or
  2. it changed, but still declares the same daemon base URL.

I want to flag (2) explicitly rather than bury it, because hash-only would have been the simpler rule. The common benign edit is a rotated credential in an otherwise unchanged profile — still endpoint A's own credential, and refusing it would orphan a lease on every key rotation. So endpoint equality, not the fact of an edit, is what separates rotation from re-pointing. If you'd rather be strict and accept the orphaned-lease warning on any edit, that's a one-line change — say so and I'll make it hash-only.

Endpoint comparison runs both sides through buildRemoteConnectionDaemonState, the same normalizer that produced the stored daemon.baseUrl, so it compares like with like rather than raw strings differing by a trailing slash. It also means a connection made with --daemon-base-url overriding the file still verifies on the hash path.

Two regressions, not one: the same-path A→B case above, and a rotated-credential-same-endpoint case that must stay green — because an edit-detecting rule that breaks rotation would look correct and quietly orphan leases in production.

pnpm check:affected --run fully green at this head.


Generated by Claude Code

@thymikee

thymikee commented Aug 6, 2026

Copy link
Copy Markdown
Member Author

Re-review: current head is still 5968975, so the latest P1 remains. The follow-up above addresses the earlier re-pointed-file repro, not the unchanged-profile CLI-override repro. In previousConfigStillSpeaksForPreviousEndpoint, a matching file hash returns true before declaredDaemonBaseUrl is compared with the stored previous daemon base URL. Supported failure: profile P unchanged declares endpoint B and token B; the initial connection uses explicit CLI endpoint A and token A, so state records endpoint A plus P's hash; connect --force later reads token B from P, the hash branch approves it, and releaseRemoteConnectionLease sends B to endpoint A. Hash equality proves file identity, not that the file supplied the effective endpoint or credential after CLI precedence. Require the normalized declared endpoint to equal the stored previous endpoint even on the hash-match branch, fail closed when no endpoint is declared, and add this exact unchanged-profile CLI-override regression. No ready label; CodeQL is queued and other visible checks are passing.

thymikee and others added 5 commits August 6, 2026 21:29
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.
…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 force-pushed the fix/remote-connection-token-strip branch from 9493ee0 to 8fca921 Compare August 6, 2026 19:29
@thymikee

thymikee commented Aug 6, 2026

Copy link
Copy Markdown
Member Author

Reconciled the remaining endpoint-provenance finding at exact head 8fca92148.

Red proof: with an unchanged profile declaring endpoint B/token B and stored previous endpoint A created through a CLI override, the pre-fix path attempted A's lease release using B's token. A matching file hash proved only unchanged bytes, not that the profile endpoint/token were effective for the stored connection.

The fix removes the hash-only shortcut: a previous profile token is eligible only when its normalized declared endpoint equals the stored previous endpoint. Same-endpoint credential rotation remains supported; missing or different endpoint provenance fails closed without sending the credential, and reconnect still surfaces the existing actionable notice.

Validation after rebasing onto origin/main: focused remote suites 52/52; pnpm check:affected --run fully green; Node integration 52 passed / 8 device-gated skipped. No device evidence applies to this remote auth/state path. PR description has been updated to match the final rule and evidence.

@thymikee thymikee added the ready-for-human Valid work that needs human implementation, judgment, or maintainer merge label Aug 6, 2026
@thymikee
thymikee merged commit b19ee11 into main Aug 6, 2026
4 checks passed
@thymikee
thymikee deleted the fix/remote-connection-token-strip branch August 6, 2026 19:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ready-for-human Valid work that needs human implementation, judgment, or maintainer merge

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants