Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
502 changes: 496 additions & 6 deletions src/__tests__/remote-connection.test.ts

Large diffs are not rendered by default.

76 changes: 53 additions & 23 deletions src/cli/commands/connection-presentation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@ export type LeasePreparationNotice = {
nextSteps: string[];
};

export type PreviousLeaseReleaseNotice = {
status: 'unreleased';
message: string;
};

export function buildLeasePreparationNotice(
state: RemoteConnectionState,
verification?: ConnectVerification,
Expand Down Expand Up @@ -77,14 +82,16 @@ export function renderConnectSuccess(options: {
state: RemoteConnectionState;
readiness?: ConnectReadiness;
runtimePreparation?: RuntimePreparationNotice;
previousLeaseNotice?: PreviousLeaseReleaseNotice;
}): string {
const { state, readiness, runtimePreparation } = options;
const { state, readiness, runtimePreparation, previousLeaseNotice } = options;
if (!readiness) {
const leasePreparation = buildLeasePreparationNotice(state);
return [
`Configured remote session "${state.session}" tenant "${state.tenant}" run "${state.runId}"${state.leaseId ? ` lease ${state.leaseId}` : ''}.`,
leasePreparation?.message,
runtimePreparation?.message,
previousLeaseNotice?.message,
]
.filter((line): line is string => Boolean(line))
.join('\n');
Expand All @@ -104,17 +111,18 @@ export function renderConnectSuccess(options: {
lines.push(...readiness.nextSteps.map((step) => ` ${step}`));
lines.push(...(readiness.notes ?? []));
if (runtimePreparation) lines.push(runtimePreparation.message);
if (previousLeaseNotice) lines.push(previousLeaseNotice.message);
return lines.join('\n');
}

export function serializeConnectionState(options: {
state: RemoteConnectionState;
runtimePreparation?: RuntimePreparationNotice;
readiness?: ConnectReadiness;
previousLeaseNotice?: PreviousLeaseReleaseNotice;
}): Record<string, unknown> {
const { state, runtimePreparation, readiness } = options;
const { state, runtimePreparation, readiness, previousLeaseNotice } = options;
const leasePreparation = buildLeasePreparationNotice(state, readiness);
const nextSteps = readiness?.nextSteps ?? leasePreparation?.nextSteps ?? [];
return {
connected: true,
session: state.session,
Expand All @@ -129,34 +137,56 @@ export function serializeConnectionState(options: {
remoteConfig: state.remoteConfigPath,
remoteConfigHash: state.remoteConfigHash,
daemonBaseUrlFingerprint: fingerprint(state.daemon?.baseUrl),
liveSession: {
status: state.leaseId ? 'created' : 'not-created',
...(state.leaseId ? { leaseId: state.leaseId } : {}),
},
...(readiness
? {
verification: {
status: connectionVerificationStatus(readiness),
service: readiness.service,
message: readiness.verificationMessage,
...(readiness.project ? { project: readiness.project } : {}),
},
...(readiness.device ? { device: readiness.device } : {}),
...(readiness.app ? { app: readiness.app } : {}),
nextSteps,
...(readiness.notes ? { notes: readiness.notes } : {}),
}
: {}),
liveSession: buildLiveSessionField(state),
...buildReadinessFields(readiness, leasePreparation),
metro: state.metro
? { prepared: true, projectRoot: state.metro.projectRoot }
: { prepared: false },
...(leasePreparation ? { leasePreparation } : {}),
...(runtimePreparation ? { runtimePreparation } : {}),
...buildConnectionNoticeFields({ leasePreparation, runtimePreparation, previousLeaseNotice }),
connectedAt: state.connectedAt,
updatedAt: state.updatedAt,
};
}

function buildLiveSessionField(state: RemoteConnectionState): Record<string, unknown> {
return {
status: state.leaseId ? 'created' : 'not-created',
...(state.leaseId ? { leaseId: state.leaseId } : {}),
};
}

function buildReadinessFields(
readiness: ConnectReadiness | undefined,
leasePreparation: LeasePreparationNotice | undefined,
): Record<string, unknown> {
if (!readiness) return {};
return {
verification: {
status: connectionVerificationStatus(readiness),
service: readiness.service,
message: readiness.verificationMessage,
...(readiness.project ? { project: readiness.project } : {}),
},
...(readiness.device ? { device: readiness.device } : {}),
...(readiness.app ? { app: readiness.app } : {}),
nextSteps: readiness.nextSteps ?? leasePreparation?.nextSteps ?? [],
...(readiness.notes ? { notes: readiness.notes } : {}),
};
}

function buildConnectionNoticeFields(options: {
leasePreparation?: LeasePreparationNotice;
runtimePreparation?: RuntimePreparationNotice;
previousLeaseNotice?: PreviousLeaseReleaseNotice;
}): Record<string, unknown> {
const { leasePreparation, runtimePreparation, previousLeaseNotice } = options;
return {
...(leasePreparation ? { leasePreparation } : {}),
...(runtimePreparation ? { runtimePreparation } : {}),
...(previousLeaseNotice ? { previousLeaseNotice } : {}),
};
}

function renderDevice(device: NonNullable<ConnectReadiness['device']>): string {
const osVersion = 'osVersion' in device ? device.osVersion : undefined;
const os = [device.platform, osVersion].filter(Boolean).join(' ');
Expand Down
143 changes: 138 additions & 5 deletions src/cli/commands/connection-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@ import { resolveDaemonPaths } from '../../daemon/config.ts';
import { stopReactDevtoolsCompanion } from '../../client/client-react-devtools-companion.ts';
import { stopMetroTunnel } from '../../metro/metro.ts';
import { resolveRemoteConfigProfile } from '../../remote/remote-config.ts';
// Provenance-preserving file-only read (no ambient env defaults merged in) —
// see resolvePreviousOwnDaemonAuthToken below for why this must not be
// resolveRemoteConfigProfile.
import { readRemoteConfigFile } from '../../remote/remote-config-core.ts';
import {
deviceFieldsFromPublicPlatform,
isIosFamily,
Expand Down Expand Up @@ -32,6 +36,7 @@ import { readMetroPrepareKind } from '../../commands/metro/prepare-kind.ts';
import { connectionProviderRequiresRemoteDaemon } from '../connection/provider-policy.ts';
import { readCloudDeviceFeatureProfileFields } from '../connection/profile-fields.ts';
import { isCloudWebDriverProviderName } from '@agent-device/provider-webdriver';
import type { PreviousLeaseReleaseNotice } from './connection-presentation.ts';

const leaseDeferredCommands = new Set([
'artifacts',
Expand Down Expand Up @@ -483,6 +488,9 @@ export async function stopReactDevtoolsCleanup(options: {
export async function releaseRemoteConnectionLease(
client: AgentDeviceClient,
state: RemoteConnectionState,
// The daemon bearer token is never persisted on `state` (ADR 0007); callers
// pass the token already resolved via the flag/env/CLI-session chain.
daemonAuthToken?: string,
): Promise<{ released: boolean; provider?: CloudProviderSessionResult }> {
if (!state.leaseId) return { released: false };
const result = await client.leases.release({
Expand All @@ -491,7 +499,7 @@ export async function releaseRemoteConnectionLease(
leaseId: state.leaseId,
leaseBackend: state.leaseBackend,
daemonBaseUrl: state.daemon?.baseUrl,
daemonAuthToken: state.daemon?.authToken,
daemonAuthToken,
daemonTransport: state.daemon?.transport,
daemonServerMode: state.daemon?.serverMode,
leaseProvider: state.leaseProvider,
Expand All @@ -501,18 +509,143 @@ export async function releaseRemoteConnectionLease(
return result;
}

// A forced reconnect releases the *previous* connection's lease, which must be
// authenticated against the *previous* endpoint's own credential — never the
// new connection's token (that would send an unrelated endpoint's secret to
// an endpoint it was never issued for). See plans/007 for the full rule.
type PreviousLeaseAuthResolution =
| { canAuthenticate: true; daemonAuthToken?: string }
| { canAuthenticate: false };

function resolvePreviousLeaseAuth(options: {
previous: RemoteConnectionState;
nextDaemonBaseUrl?: string;
ambientDaemonAuthToken?: string;
cwd: string;
env: Record<string, string | undefined>;
}): PreviousLeaseAuthResolution {
const ownToken = resolvePreviousOwnDaemonAuthToken(options.previous, options.cwd, options.env);
if (ownToken) return { canAuthenticate: true, daemonAuthToken: ownToken };
if (options.previous.daemon?.baseUrl === options.nextDaemonBaseUrl) {
// Same endpoint: the ambient credential plausibly belongs to it too.
return { canAuthenticate: true, daemonAuthToken: options.ambientDaemonAuthToken };
}
return { canAuthenticate: false };
}

function resolvePreviousOwnDaemonAuthToken(
previous: RemoteConnectionState,
cwd: string,
env: Record<string, string | undefined>,
): string | undefined {
try {
// readRemoteConfigFile, not resolveRemoteConfigProfile: the latter merges
// ambient environment defaults (e.g. AGENT_DEVICE_DAEMON_AUTH_TOKEN) into
// the profile, which would let the *new* connection's env-sourced token
// masquerade as a credential that provably belongs to the *previous*
// endpoint. Only a token the previous config file itself declares counts
// here; the env fallback is rule 2's job, gated on matching endpoints.
const { profile } = readRemoteConfigFile({
configPath: previous.remoteConfigPath,
cwd,
env,
});
if (!profile.daemonAuthToken) return undefined;
// The path alone is not provenance. `remoteConfigPath` names a file *now*,
// while the claim being made is about what that file declared when the
// previous connection was established — and a config path is routinely
// reused (edited in place, re-pointed at a second environment) between the
// two. Without this check, "connect to A from ./remote.json, re-point
// ./remote.json at B, connect --force" reads B's token as A's own and
// sends it to A during lease release: the same cross-endpoint leak the
// env-merge fix closed, arriving through the file instead.
return previousConfigStillSpeaksForPreviousEndpoint(previous, profile.daemonBaseUrl)
? profile.daemonAuthToken
: undefined;
} catch {
// A missing/unparseable previous config is the "cannot authenticate"
// case handled by the caller, not an error to propagate here.
return undefined;
}
}

/**
* Whether the previous connection's config file can still vouch for a token as
* belonging to the previous connection's endpoint.
*
* The file must explicitly declare the same endpoint recorded in the previous
* connection state. A matching file hash proves only that the file itself did
* not change; it does not prove that its endpoint/token were effective when
* CLI flags may have overridden them. Endpoint equality is the provenance
* boundary and also preserves the benign rotated-credential case.
*
* The 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 that differ only by a trailing slash.
*
* A file that changed and no longer declares an endpoint at all cannot vouch
* for anything: the caller then falls back to rule 2 (matching endpoints) or
* reports the lease as unreleasable, which is a warning and an orphaned lease
* — the correct price for not sending a credential somewhere it may not belong.
*/
function previousConfigStillSpeaksForPreviousEndpoint(
previous: RemoteConnectionState,
declaredDaemonBaseUrl: string | undefined,
): boolean {
const declared = buildRemoteConnectionDaemonState({
daemonBaseUrl: declaredDaemonBaseUrl,
})?.baseUrl;
return declared !== undefined && declared === previous.daemon?.baseUrl;
}

export async function releasePreviousLease(
client: AgentDeviceClient,
previous: RemoteConnectionState,
): Promise<void> {
if (!previous.leaseId) return;
options: {
nextDaemonBaseUrl?: string;
ambientDaemonAuthToken?: string;
cwd: string;
env: Record<string, string | undefined>;
},
): Promise<PreviousLeaseReleaseNotice | undefined> {
if (!previous.leaseId) return undefined;
const auth = resolvePreviousLeaseAuth({
previous,
nextDaemonBaseUrl: options.nextDaemonBaseUrl,
ambientDaemonAuthToken: options.ambientDaemonAuthToken,
cwd: options.cwd,
env: options.env,
});
if (!auth.canAuthenticate) {
return buildUnreleasedPreviousLeaseNotice(
previous,
'no credential known to belong to that endpoint was available',
);
}
try {
await releaseRemoteConnectionLease(client, previous);
await releaseRemoteConnectionLease(client, previous, auth.daemonAuthToken);
return undefined;
} catch {
// Reconnect must succeed even if the old lease was already released.
// Reconnect must still succeed; surface the failure instead of hiding it.
return buildUnreleasedPreviousLeaseNotice(previous, 'the release request failed');
}
}

function buildUnreleasedPreviousLeaseNotice(
previous: RemoteConnectionState,
reason: string,
): PreviousLeaseReleaseNotice {
return {
status: 'unreleased',
message:
`Could not release the previous lease ${previous.leaseId} ` +
`(tenant ${previous.tenant}, run ${previous.runId}) ` +
`at ${previous.daemon?.baseUrl ?? 'its daemon'}: ${reason}. ` +
'It was left in place — release it manually if it is still active.',
};
}

async function releaseAcquiredLeaseOnWriteFailure(
client: AgentDeviceClient,
state: RemoteConnectionState,
Expand Down
27 changes: 20 additions & 7 deletions src/cli/commands/connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import {
presentConnectReadiness,
renderConnectSuccess,
serializeConnectionState,
type PreviousLeaseReleaseNotice,
type RuntimePreparationNotice,
} from './connection-presentation.ts';

Expand Down Expand Up @@ -81,14 +82,20 @@ export const connectCommand: ClientCommandHandler = async ({ positionals, flags,
remoteConfigPath: resolved.remoteConfigPath,
});
writeRemoteConnectionState({ stateDir, state });
await cleanupForcedPreviousConnection(client, stateDir, connectFlags, context.previous);
const previousLeaseNotice = await cleanupForcedPreviousConnection(
client,
stateDir,
connectFlags,
context.previous,
state.daemon?.baseUrl,
);
const runtimePreparation = buildRuntimePreparationNotice(connectFlags, state);
const readiness = presentConnectReadiness(state, verification);

writeCommandOutput(
connectFlags,
serializeConnectionState({ state, runtimePreparation, readiness }),
() => renderConnectSuccess({ state, runtimePreparation, readiness }),
serializeConnectionState({ state, runtimePreparation, readiness, previousLeaseNotice }),
() => renderConnectSuccess({ state, runtimePreparation, readiness, previousLeaseNotice }),
);
return true;
};
Expand Down Expand Up @@ -240,11 +247,17 @@ async function cleanupForcedPreviousConnection(
stateDir: string,
flags: CliFlags,
previous: RemoteConnectionState | null,
): Promise<void> {
if (!previous || !flags.force) return;
nextDaemonBaseUrl: string | undefined,
): Promise<PreviousLeaseReleaseNotice | undefined> {
if (!previous || !flags.force) return undefined;
await stopMetroCleanup(previous.metro);
await stopReactDevtoolsCleanup({ stateDir, state: previous });
await releasePreviousLease(client, previous);
return await releasePreviousLease(client, previous, {
nextDaemonBaseUrl,
ambientDaemonAuthToken: flags.daemonAuthToken,
cwd: process.cwd(),
env: process.env,
});
}

function readRemoteConfigConnectionMetadata(
Expand Down Expand Up @@ -286,7 +299,7 @@ export const disconnectCommand: ClientCommandHandler = async ({ flags, client })
let released = false;
if (state.leaseId) {
try {
const release = await releaseRemoteConnectionLease(client, state);
const release = await releaseRemoteConnectionLease(client, state, flags.daemonAuthToken);
released = release.released;
providerData ??= release.provider;
} catch {
Expand Down
Loading