From 3541b513dc85820d4797a34c3e42067d150994b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Mon, 5 Oct 2026 07:52:18 +0200 Subject: [PATCH 1/3] fix(providers): refuse lease allocation when the daemon holds other provider credentials A local daemon keeps the environment it started with, while connect checks the shell's. A stale daemon could act on old credentials, for example creating a billed Limrun instance for an attach-mode shell. On a local lease_allocate the CLI now sends a versioned hash of the values each provider's own reader uses (Limrun, BrowserStack), over the socket and local HTTP; the daemon compares it with the hash of its startup environment and refuses with INVALID_ARGS, reason provider-credentials-changed, and a hint naming its state dir. No hash is sent from a shell without credentials or to remote, proxy, or cloud daemons; a daemon with an HTTP auth hook treats every caller as remote. AWS Device Farm is excluded. --- packages/kernel/src/contracts.ts | 2 + packages/provider-webdriver/src/index.ts | 1 + .../src/provider-definitions.ts | 44 +++-- packages/provider-webdriver/src/providers.ts | 16 ++ src/cli/connection/cloud-webdriver-profile.ts | 10 +- .../connection/connect-provider-adapters.ts | 10 +- src/commands/schema/cli-help.ts | 1 + ...daemon-client-provider-credentials.test.ts | 158 ++++++++++++++++++ src/daemon-client/daemon-client-rpc.ts | 3 + src/daemon-client/daemon-client.ts | 16 ++ .../http-server-rpc-validation.test.ts | 48 ++++++ .../__tests__/human-control-router-fixture.ts | 2 + .../__tests__/request-router-open.test.ts | 2 + ...equest-router-provider-credentials.test.ts | 59 +++++++ .../__tests__/test-device-runtime-gateway.ts | 13 +- src/daemon/handlers/__tests__/lease.test.ts | 127 ++++++++++++++ src/daemon/handlers/lease.ts | 30 ++++ src/daemon/request-handler-chain.ts | 3 + src/daemon/request-router.ts | 4 + ...mon-runtime-interactor-composition.test.ts | 50 ++++++ src/daemon/server/daemon-runtime.ts | 2 + src/daemon/server/http-server.ts | 14 +- src/provider-credential-fingerprint.test.ts | 79 +++++++++ src/provider-credential-fingerprint.ts | 67 ++++++++ src/provider-limrun-credentials.ts | 28 +++- .../integration/provider-scenarios/harness.ts | 3 + test/wire-compat/ledger.json | 10 +- website/docs/docs/browserstack.md | 2 + website/docs/docs/limrun.md | 2 + 29 files changed, 754 insertions(+), 52 deletions(-) create mode 100644 src/daemon-client/__tests__/daemon-client-provider-credentials.test.ts create mode 100644 src/daemon/__tests__/request-router-provider-credentials.test.ts create mode 100644 src/provider-credential-fingerprint.test.ts create mode 100644 src/provider-credential-fingerprint.ts diff --git a/packages/kernel/src/contracts.ts b/packages/kernel/src/contracts.ts index 0649955053..c87113a0ba 100644 --- a/packages/kernel/src/contracts.ts +++ b/packages/kernel/src/contracts.ts @@ -148,6 +148,8 @@ export type DaemonRequestMeta = { leaseProvider?: string; deviceKey?: string; clientId?: string; + /** A local caller's digest of its lease-provider credential variables, compared on allocation. */ + providerCredentialFingerprint?: string; sessionIsolation?: SessionIsolationMode; uploadedArtifactId?: string; clientArtifactPaths?: Record; diff --git a/packages/provider-webdriver/src/index.ts b/packages/provider-webdriver/src/index.ts index 1ff12c136f..8d9cc66934 100644 --- a/packages/provider-webdriver/src/index.ts +++ b/packages/provider-webdriver/src/index.ts @@ -21,6 +21,7 @@ import type { CloudWebDriverRuntime } from './runtime.ts'; export { CLOUD_WEBDRIVER_PROFILE_FIELDS, CLOUD_WEBDRIVER_PROVIDERS }; export { readAwsDeviceFarmRegionFromArn }; export { parseBrowserStackAppReference } from './browserstack.ts'; +export { requireBrowserStackCredentials } from './provider-definitions.ts'; export type { CloudWebDriverKnownProviderName } from './providers.ts'; export type { ProviderWebDriverDependencies, RunHostCommand } from './dependencies.ts'; export type { diff --git a/packages/provider-webdriver/src/provider-definitions.ts b/packages/provider-webdriver/src/provider-definitions.ts index 0cd3d63d68..e0bc328033 100644 --- a/packages/provider-webdriver/src/provider-definitions.ts +++ b/packages/provider-webdriver/src/provider-definitions.ts @@ -22,7 +22,12 @@ import { buildBrowserStackDeviceFeatureCapabilities, readBrowserStackDeviceFeatureFields, } from './browserstack-device-features.ts'; -import { CLOUD_WEBDRIVER_PROVIDERS, type CloudWebDriverKnownProviderName } from './providers.ts'; +import { + BROWSERSTACK_CREDENTIAL_VARIABLES, + CLOUD_WEBDRIVER_PROVIDERS, + readBrowserStackCredentials, + type CloudWebDriverKnownProviderName, +} from './providers.ts'; import { readAwsDeviceFarmRegionFromArn } from './connection-verification.ts'; import { buildCloudWebDriverBaseCapabilities, @@ -142,14 +147,8 @@ export function createCloudWebDriverProviderDefinitions( endpoint: env.BROWSERSTACK_WEBDRIVER_ENDPOINT ?? BROWSERSTACK_APP_AUTOMATE_ENDPOINT, capabilityOverrides: BROWSERSTACK_CAPABILITY_OVERRIDES, listArtifacts: async ({ provider, providerSessionId }) => { - const username = requireEnv( - env, - 'BROWSERSTACK_USERNAME', - 'BrowserStack artifact lookup', - ); - const accessKey = requireEnv( + const { username, accessKey } = requireBrowserStackCredentials( env, - 'BROWSERSTACK_ACCESS_KEY', 'BrowserStack artifact lookup', ); return await listBrowserStackCloudArtifacts(provider, providerSessionId, { @@ -161,8 +160,7 @@ export function createCloudWebDriverProviderDefinitions( }, prepareSession: async ({ req, lease, base }) => { const request = requireRequest(req, 'BrowserStack'); - const username = requireEnv(env, 'BROWSERSTACK_USERNAME', 'BrowserStack'); - const accessKey = requireEnv(env, 'BROWSERSTACK_ACCESS_KEY', 'BrowserStack'); + const { username, accessKey } = requireBrowserStackCredentials(env, 'BrowserStack'); const platform = requireRequestPlatform(request, 'BrowserStack'); const deviceName = requireFlag( request, @@ -220,10 +218,8 @@ export function createCloudWebDriverProviderDefinitions( }, }), listArtifactsFromEnv: async (providerSessionId, env) => { - const username = requireEnv(env, 'BROWSERSTACK_USERNAME', 'BrowserStack artifact lookup'); - const accessKey = requireEnv( + const { username, accessKey } = requireBrowserStackCredentials( env, - 'BROWSERSTACK_ACCESS_KEY', 'BrowserStack artifact lookup', ); return await listBrowserStackCloudArtifacts( @@ -343,16 +339,6 @@ function readFlag(req: LeaseLifecycleContext, key: string): string | undefined { return typeof value === 'string' && value.length > 0 ? value : undefined; } -function requireEnv( - env: DefaultCloudWebDriverProviderRuntimeEnv, - key: keyof DefaultCloudWebDriverProviderRuntimeEnv, - providerLabel: string, -): string { - const value = env[key]; - if (value) return value; - throw new AppError('INVALID_ARGS', `${providerLabel} requires ${key} in the environment.`); -} - function requireAwsValue( req: LeaseLifecycleContext, env: DefaultCloudWebDriverProviderRuntimeEnv, @@ -379,3 +365,15 @@ function readAwsInteractionMode( function dasherize(value: string): string { return value.replaceAll(/[A-Z]/g, (match) => `-${match.toLowerCase()}`); } + +export function requireBrowserStackCredentials( + env: Readonly>, + consumer: string, +): { username: string; accessKey: string } { + const { username, accessKey } = readBrowserStackCredentials(env); + if (username && accessKey) return { username, accessKey }; + const missing = username + ? BROWSERSTACK_CREDENTIAL_VARIABLES.accessKey + : BROWSERSTACK_CREDENTIAL_VARIABLES.username; + throw new AppError('INVALID_ARGS', `${consumer} requires ${missing} in the environment.`); +} diff --git a/packages/provider-webdriver/src/providers.ts b/packages/provider-webdriver/src/providers.ts index d0d9ab6e4f..04ae5e4d5a 100644 --- a/packages/provider-webdriver/src/providers.ts +++ b/packages/provider-webdriver/src/providers.ts @@ -14,6 +14,22 @@ export function isCloudWebDriverProviderName( return provider !== undefined && CLOUD_WEBDRIVER_KNOWN_PROVIDERS.has(provider); } +/** The environment variables that hold BrowserStack credentials. */ +export const BROWSERSTACK_CREDENTIAL_VARIABLES = { + username: 'BROWSERSTACK_USERNAME', + accessKey: 'BROWSERSTACK_ACCESS_KEY', +} as const; + +/** The BrowserStack credentials in the environment, exactly as every consumer and the fingerprint use them. */ +export function readBrowserStackCredentials( + env: Readonly>, +): Readonly<{ username?: string; accessKey?: string }> { + return { + username: env[BROWSERSTACK_CREDENTIAL_VARIABLES.username] || undefined, + accessKey: env[BROWSERSTACK_CREDENTIAL_VARIABLES.accessKey] || undefined, + }; +} + const BROWSERSTACK_APP_SCHEME = 'bs://'; /** diff --git a/src/cli/connection/cloud-webdriver-profile.ts b/src/cli/connection/cloud-webdriver-profile.ts index 049a707a75..9481aac292 100644 --- a/src/cli/connection/cloud-webdriver-profile.ts +++ b/src/cli/connection/cloud-webdriver-profile.ts @@ -3,6 +3,7 @@ import { CLOUD_WEBDRIVER_PROVIDERS, parseBrowserStackAppReference, readAwsDeviceFarmRegionFromArn, + requireBrowserStackCredentials, type CloudWebDriverKnownProviderName, } from '@agent-device/provider-webdriver'; import { rejectRefusedProviderProfileFields } from '@agent-device/contracts/provider-profile-fields'; @@ -99,8 +100,7 @@ function browserStackProfileFields(options: { env?: EnvMap; cwd: string; }): RemoteConfigProfile { - requireEnv(options.env, 'BROWSERSTACK_USERNAME', 'connect browserstack'); - requireEnv(options.env, 'BROWSERSTACK_ACCESS_KEY', 'connect browserstack'); + requireBrowserStackCredentials(options.env ?? {}, 'connect browserstack'); const platform = requireCloudWebDriverPlatform( options.flags.platform, 'connect browserstack requires --platform ios|android.', @@ -195,12 +195,6 @@ function requireFlag(value: string | undefined, message: string): string { throw new AppError('INVALID_ARGS', message); } -function requireEnv(env: EnvMap | undefined, name: string, command: string): string { - const value = env?.[name]; - if (value) return value; - throw new AppError('INVALID_ARGS', `${command} requires ${name} in the environment.`); -} - function requireAwsProfileValue( flagValue: string | undefined, env: EnvMap | undefined, diff --git a/src/cli/connection/connect-provider-adapters.ts b/src/cli/connection/connect-provider-adapters.ts index fe41e812c0..2e22a4d7f8 100644 --- a/src/cli/connection/connect-provider-adapters.ts +++ b/src/cli/connection/connect-provider-adapters.ts @@ -2,6 +2,7 @@ import type { CliFlags } from '@agent-device/contracts/command'; import type { ProviderConnectionVerification } from '@agent-device/contracts/remote'; import { verifyLimrunConnection } from '@agent-device/provider-limrun'; import { AppError } from '@agent-device/kernel/errors'; +import { requireBrowserStackCredentials } from '@agent-device/provider-webdriver'; import { providerWebDriver } from '../../provider-webdriver.ts'; import { resolveRemoteConfigProfile } from '../../remote/remote-config.ts'; import { readVersion } from '@agent-device/host-kit/version'; @@ -136,14 +137,7 @@ async function verifyBrowserStack( const { flags, env } = context; return await providerWebDriver.verifyConnection({ provider: 'browserstack', - username: requiredResolvedValue( - env.BROWSERSTACK_USERNAME, - 'BrowserStack profile missed BROWSERSTACK_USERNAME.', - ), - accessKey: requiredResolvedValue( - env.BROWSERSTACK_ACCESS_KEY, - 'BrowserStack profile missed BROWSERSTACK_ACCESS_KEY.', - ), + ...requireBrowserStackCredentials(env, 'BrowserStack'), platform: requiredResolvedPlatform(flags.platform, 'BrowserStack'), deviceName: requiredResolvedValue(flags.device, 'BrowserStack profile missed device.'), osVersion: requiredResolvedValue( diff --git a/src/commands/schema/cli-help.ts b/src/commands/schema/cli-help.ts index 0ac4b4829f..34050c1cb4 100644 --- a/src/commands/schema/cli-help.ts +++ b/src/commands/schema/cli-help.ts @@ -667,6 +667,7 @@ Rules: Use agent-device proxy for direct tunnel access to a Mac you control. Expose the printed proxy URL through cloudflared/ngrok, then run agent-device connect proxy with the tunnel URL and printed token before normal commands. Use Limrun, BrowserStack, and AWS Device Farm through local provider profiles; they do not accept a remote agent-device daemon URL. Device cloud credentials must be available before the command starts. Limrun uses LIMRUN_API_KEY, or the LIM_*_INSTANCE_* variables for an existing instance. BrowserStack uses BROWSERSTACK_USERNAME and BROWSERSTACK_ACCESS_KEY. AWS Device Farm uses the AWS CLI credential chain, including CI-provided AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY/AWS_SESSION_TOKEN, AWS profiles, or web identity role variables. + A local daemon keeps the Limrun and BrowserStack credentials it started with. When the shell holds different ones, the first command that allocates a lease refuses with reason provider-credentials-changed; run agent-device daemon stop with the same --state-dir, then rerun the command. A shell that sets none of them uses the daemon's. A daemon with an HTTP auth hook serves remote callers and does not compare. Direct-provider connect performs read-only provider calls and saves active connection state only after verification succeeds. It never creates a device, instance, App Automate session, or AWS remote access session. connect without --session always creates a fresh remote session and prints that session in its next-step commands. Concurrent callers must pass the returned --session on every command; the ambient active connection is only a single-workflow convenience. To replace an existing connection, pass its returned session explicitly with --session --force. --force without --session creates another fresh session and does not release or overwrite an unrelated active connection. diff --git a/src/daemon-client/__tests__/daemon-client-provider-credentials.test.ts b/src/daemon-client/__tests__/daemon-client-provider-credentials.test.ts new file mode 100644 index 0000000000..6e5e3a3d8f --- /dev/null +++ b/src/daemon-client/__tests__/daemon-client-provider-credentials.test.ts @@ -0,0 +1,158 @@ +import { afterEach, expect, test, vi } from 'vitest'; +import fs from 'node:fs'; +import net from 'node:net'; +import { readProcessStartTime } from '@agent-device/host-kit/process'; +import { readVersion } from '@agent-device/host-kit/version'; +import { sendToDaemon } from '../daemon-client.ts'; +import { resolveDaemonPaths } from '../../daemon-resolution.ts'; +import { + currentDaemonCodeSignature, + startHttpDaemonFixture, +} from '../../__tests__/test-utils/daemon-http-fixture.ts'; +import { + closeLoopbackServer, + listenOnLoopback, + supportsLoopbackBind, +} from '../../__tests__/test-utils/loopback.ts'; +import { mkdtempForTestSync } from '../../__tests__/test-utils/tmp-dir.ts'; +import { providerCredentialFingerprint } from '../../provider-credential-fingerprint.ts'; +import { LIMRUN_CREDENTIAL_VARIABLES } from '../../provider-limrun-credentials.ts'; + +const API_KEY = 'lim-secret-key'; + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +type CapturedDaemon = { bodies: string[]; close: () => Promise }; + +test.sequential.for(['socket', 'http'] as const)( + 'a local %s daemon gets a fingerprint, never the key, only on lease_allocate with credentials', + async (transport, t) => { + if (!(await supportsLoopbackBind())) { + t.skip('loopback listeners are not permitted in this environment'); + return; + } + clearCredentialEnv(); + vi.stubEnv('AGENT_DEVICE_DAEMON_BASE_URL', undefined); + vi.stubEnv('AGENT_DEVICE_DAEMON_AUTH_TOKEN', undefined); + const stateDir = mkdtempForTestSync('agent-device-provider-credentials-daemon-'); + const daemon = await startLocalDaemon(stateDir, transport); + const send = (command: string) => + sendToDaemon({ + session: 'default', + command, + positionals: [], + flags: { stateDir, daemonTransport: transport }, + meta: { requestId: `req-${command}`, leaseProvider: 'limrun', tenantId: 'tenant-a' }, + }); + + try { + vi.stubEnv('LIMRUN_API_KEY', API_KEY); + await send('lease_allocate'); + await send('devices'); + vi.stubEnv('LIMRUN_API_KEY', undefined); + await send('lease_allocate'); + } finally { + await daemon.close(); + fs.rmSync(stateDir, { recursive: true, force: true }); + } + + expect(daemon.bodies.map(readFingerprint)).toEqual([ + providerCredentialFingerprint('limrun', { LIMRUN_API_KEY: API_KEY }), + undefined, + undefined, + ]); + for (const body of daemon.bodies) expect(body).not.toContain(API_KEY); + }, +); + +test.sequential('a remote daemon gets no provider credential fingerprint', async (t) => { + if (!(await supportsLoopbackBind())) { + t.skip('loopback listeners are not permitted in this environment'); + return; + } + clearCredentialEnv(); + vi.stubEnv('LIMRUN_API_KEY', API_KEY); + const remote = await startHttpDaemonFixture({}); + vi.stubEnv('AGENT_DEVICE_DAEMON_BASE_URL', `http://127.0.0.1:${remote.port}`); + vi.stubEnv('AGENT_DEVICE_DAEMON_AUTH_TOKEN', 'remote-secret'); + + try { + await sendToDaemon({ + session: 'default', + command: 'lease_allocate', + positionals: [], + meta: { requestId: 'req-remote-lease', leaseProvider: 'limrun', tenantId: 'tenant-a' }, + }); + } finally { + await closeLoopbackServer(remote.server); + } + + expect(remote.rpcRequests.map((rpc) => rpc.method)).toEqual(['agent_device.lease.allocate']); + const body = JSON.stringify(remote.rpcRequests[0]); + expect(readFingerprint(body)).toBe(undefined); + expect(body).not.toContain(API_KEY); +}); + +function readFingerprint(body: string): unknown { + const parsed = JSON.parse(body) as { + meta?: { providerCredentialFingerprint?: unknown }; + params?: { providerCredentialFingerprint?: unknown; meta?: Record }; + }; + return ( + parsed.meta?.providerCredentialFingerprint ?? + parsed.params?.providerCredentialFingerprint ?? + parsed.params?.meta?.providerCredentialFingerprint + ); +} + +function clearCredentialEnv(): void { + for (const name of LIMRUN_CREDENTIAL_VARIABLES) vi.stubEnv(name, undefined); +} + +async function startLocalDaemon( + stateDir: string, + transport: 'socket' | 'http', +): Promise { + if (transport === 'http') { + const daemon = await startHttpDaemonFixture({}); + writeDaemonInfo(stateDir, { httpPort: daemon.port, transport }); + return { + get bodies() { + return daemon.rpcRequests.map((rpc) => JSON.stringify(rpc)); + }, + close: () => closeLoopbackServer(daemon.server), + }; + } + const bodies: string[] = []; + const server = net.createServer((socket) => { + socket.setEncoding('utf8'); + let body = ''; + socket.on('data', (chunk) => { + body += chunk; + if (!body.includes('\n')) return; + bodies.push(body.trim()); + socket.end(`${JSON.stringify({ ok: true, data: {} })}\n`); + }); + }); + writeDaemonInfo(stateDir, { port: await listenOnLoopback(server), transport }); + return { bodies, close: () => closeLoopbackServer(server) }; +} + +function writeDaemonInfo(stateDir: string, endpoint: Record): void { + const paths = resolveDaemonPaths(stateDir); + fs.mkdirSync(paths.baseDir, { recursive: true }); + fs.writeFileSync( + paths.infoPath, + `${JSON.stringify({ + ...endpoint, + token: 'local-secret', + pid: process.pid, + version: readVersion(), + codeSignature: currentDaemonCodeSignature(), + processStartTime: readProcessStartTime(process.pid) ?? undefined, + })}\n`, + 'utf8', + ); +} diff --git a/src/daemon-client/daemon-client-rpc.ts b/src/daemon-client/daemon-client-rpc.ts index 4cdf6d54fb..8da929e319 100644 --- a/src/daemon-client/daemon-client-rpc.ts +++ b/src/daemon-client/daemon-client-rpc.ts @@ -185,6 +185,9 @@ export function buildHttpRpcPayload( params: { ...buildLeaseRpcParams(req, req.command, options), ...(req.command === 'lease_allocate' ? readLeaseAllocateProviderFlags(req.flags) : {}), + ...(req.command === 'lease_allocate' && req.meta?.providerCredentialFingerprint + ? { providerCredentialFingerprint: req.meta.providerCredentialFingerprint } + : {}), }, }; } diff --git a/src/daemon-client/daemon-client.ts b/src/daemon-client/daemon-client.ts index 3dc4bfab29..f5e010803f 100644 --- a/src/daemon-client/daemon-client.ts +++ b/src/daemon-client/daemon-client.ts @@ -80,6 +80,7 @@ export async function sendToDaemon( info, requestId, debug, + await readLocalProviderCredentialFingerprint(requestWithoutAuthFlag, info), ); emitDiagnostic({ level: 'info', @@ -119,6 +120,7 @@ function buildTransportRequest( info: DaemonInfo, requestId: string, debug: boolean, + providerCredentialFingerprint: string | undefined, ): DaemonRequest { return { ...request, @@ -128,10 +130,24 @@ function buildTransportRequest( meta: { ...buildTransportRequestMeta(request, preparedRemoteRequest, requestId, debug), ...buildLocalHostEnvMeta(info), + providerCredentialFingerprint, }, }; } +// A remote daemon reads provider credentials from its own host, so only a local daemon is asked +// to compare them, and only when a lease is allocated. +async function readLocalProviderCredentialFingerprint( + request: Omit, + info: DaemonInfo, +): Promise { + if (isRemoteDaemon(info) || request.command !== 'lease_allocate') return undefined; + const provider = leaseScopeFromRequest(request).leaseProvider; + if (!provider) return undefined; + const { providerCredentialFingerprint } = await import('../provider-credential-fingerprint.ts'); + return providerCredentialFingerprint(provider, process.env); +} + // A developer dir is a path on the client's host, so only a local daemon can use it. function buildLocalHostEnvMeta( info: DaemonInfo, diff --git a/src/daemon/__tests__/http-server-rpc-validation.test.ts b/src/daemon/__tests__/http-server-rpc-validation.test.ts index 3d2add7b77..2d215b12e6 100644 --- a/src/daemon/__tests__/http-server-rpc-validation.test.ts +++ b/src/daemon/__tests__/http-server-rpc-validation.test.ts @@ -399,6 +399,54 @@ test('only local command RPC keeps the client developer dir', async (t) => { } }); +test('only local lease allocation RPC keeps the provider credential fingerprint', async (t) => { + if (await skipWhenLoopbackUnavailable(t)) return; + const root = mkdtempForTestSync('agent-device-http-provider-credentials-'); + const fingerprintSeenBy = async (env: NodeJS.ProcessEnv): Promise => { + const received: DaemonRequest[] = []; + const server = await createDaemonHttpServer({ + env, + handleRequest: async (request): Promise => { + received.push(request); + return { ok: true, data: {} }; + }, + }); + try { + const response = await fetch(`http://127.0.0.1:${await listenOnLoopback(server)}/rpc`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 'lease-allocate-fingerprint', + method: 'agent_device.lease.allocate', + params: { + tenantId: 'tenant-test', + runId: 'run-a', + backend: 'ios-instance', + provider: 'limrun', + providerCredentialFingerprint: 'v1:0123456789abcdef', + }, + }), + }); + assert.equal(response.status, 200); + assert.equal(received[0]?.command, 'lease_allocate'); + return received[0]?.meta?.providerCredentialFingerprint; + } finally { + await closeLoopbackServer(server); + } + }; + + try { + assert.equal(await fingerprintSeenBy(localHttpEnvironment()), 'v1:0123456789abcdef'); + assert.equal( + await fingerprintSeenBy(remoteHttpEnvironment(writeAllowingAuthHook(root))), + undefined, + ); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + function writeAllowingAuthHook(root: string): string { const hookPath = path.join(root, 'auth-hook.mjs'); fs.writeFileSync(hookPath, "export default () => ({ tenantId: 'tenant-test' });\n"); diff --git a/src/daemon/__tests__/human-control-router-fixture.ts b/src/daemon/__tests__/human-control-router-fixture.ts index ddecdf3307..f4af99ddcb 100644 --- a/src/daemon/__tests__/human-control-router-fixture.ts +++ b/src/daemon/__tests__/human-control-router-fixture.ts @@ -4,6 +4,7 @@ import { makeSessionStore } from '../../__tests__/test-utils/store-factory.ts'; import { buildSessionLeaseFromRequest } from '../lease-context.ts'; import { LeaseRegistry } from '../lease-registry.ts'; import { createRequestHandler } from '../request-router.ts'; +import { readDaemonProviderCredentials } from '../../provider-credential-fingerprint.ts'; import { tenantScopedSessionName } from '../session-tenant-scope.ts'; import { lifecycleDeviceRuntimeGateway } from './test-device-runtime-gateway.ts'; import { HUMAN_CONTROL_LEASE_REQUEST, humanControlRequest } from './human-control-fixtures.ts'; @@ -24,6 +25,7 @@ export function createHumanControlHarness() { token: 'test-token', sessionStore, leaseRegistry: registry, + providerCredentials: readDaemonProviderCredentials({}, '/tmp'), deviceInventoryGateways: createTestDeviceInventoryGateways(), deviceRuntimeGateway: lifecycleDeviceRuntimeGateway, trackDownloadableArtifact: () => 'artifact-1', diff --git a/src/daemon/__tests__/request-router-open.test.ts b/src/daemon/__tests__/request-router-open.test.ts index e73f702fcb..41e7dedd8b 100644 --- a/src/daemon/__tests__/request-router-open.test.ts +++ b/src/daemon/__tests__/request-router-open.test.ts @@ -72,6 +72,7 @@ import { lifecycleDeviceRuntimeGateway, } from './test-device-runtime-gateway.ts'; import { createRequestHandler as createProductionRequestHandler } from '../request-router.ts'; +import { readDaemonProviderCredentials } from '../../provider-credential-fingerprint.ts'; import { resolveRequestExecutionLockPlan } from '../request-binding.ts'; import { LeaseRegistry } from '../lease-registry.ts'; import { ensureDeviceReady } from '../device/device-ready.ts'; @@ -491,6 +492,7 @@ test('close fails synchronously when root composition omits platform resource cl token: 'test-token', sessionStore, leaseRegistry: new LeaseRegistry(), + providerCredentials: readDaemonProviderCredentials({}, '/tmp'), deviceRuntimeGateway: lifecycleDeviceRuntimeGateway, deviceInventoryGateways: createTestDeviceInventoryGateways(), trackDownloadableArtifact: () => 'artifact-id', diff --git a/src/daemon/__tests__/request-router-provider-credentials.test.ts b/src/daemon/__tests__/request-router-provider-credentials.test.ts new file mode 100644 index 0000000000..53b0d20a35 --- /dev/null +++ b/src/daemon/__tests__/request-router-provider-credentials.test.ts @@ -0,0 +1,59 @@ +import path from 'node:path'; +import { expect, test } from 'vitest'; +import { createTestDeviceInventoryGateways } from '../../__tests__/test-utils/device-inventory-gateways.ts'; +import { makeSessionStore } from '../../__tests__/test-utils/store-factory.ts'; +import { mkdtempForTestSync } from '../../__tests__/test-utils/tmp-dir.ts'; +import { + providerCredentialFingerprint, + readDaemonProviderCredentials, +} from '../../provider-credential-fingerprint.ts'; +import { LeaseRegistry } from '../lease-registry.ts'; +import { createRequestHandler } from './test-device-runtime-gateway.ts'; + +const DAEMON_ENV = { LIMRUN_API_KEY: 'lim-key' }; + +test.for([ + [{ LIMRUN_API_KEY: 'lim-rotated' }, 'provider-credentials-changed', 0], + [DAEMON_ENV, undefined, 1], +] as const)( + 'the router hands the daemon provider credentials to lease allocation (shell %j)', + async ([shellEnv, reason, allocations]) => { + const stateDir = mkdtempForTestSync('agent-device-router-provider-credentials-'); + let allocated = 0; + const handler = createRequestHandler({ + logPath: path.join(stateDir, 'daemon.log'), + token: 'test-token', + sessionStore: makeSessionStore('agent-device-router-provider-credentials-store-'), + leaseRegistry: new LeaseRegistry(), + deviceInventoryGateways: createTestDeviceInventoryGateways(), + providerCredentials: readDaemonProviderCredentials(DAEMON_ENV, stateDir), + leaseLifecycleProvider: { + allocate: async () => { + allocated += 1; + return {}; + }, + }, + trackDownloadableArtifact: () => 'artifact-id', + }); + + const response = await handler({ + token: 'test-token', + session: 'default', + command: 'lease_allocate', + positionals: [], + flags: {}, + meta: { + requestId: 'req-provider-credentials', + tenantId: 'tenant-a', + runId: 'run-a', + clientId: 'client-a', + leaseBackend: 'ios-instance', + leaseProvider: 'limrun', + providerCredentialFingerprint: providerCredentialFingerprint('limrun', shellEnv), + }, + }); + + expect(response.ok ? undefined : response.error.details?.reason).toBe(reason); + expect(allocated).toBe(allocations); + }, +); diff --git a/src/daemon/__tests__/test-device-runtime-gateway.ts b/src/daemon/__tests__/test-device-runtime-gateway.ts index 4c1cd68ca1..1e60d65474 100644 --- a/src/daemon/__tests__/test-device-runtime-gateway.ts +++ b/src/daemon/__tests__/test-device-runtime-gateway.ts @@ -1,3 +1,4 @@ +import path from 'node:path'; import { vi } from 'vitest'; import { type ApplicationLifecycleOperationFacts, @@ -25,6 +26,7 @@ import { withClientReplayScriptSources } from '../../__tests__/test-utils/replay import type { DaemonInvokeFn } from '../daemon-request.ts'; import { clearAndroidObservationFixture } from './android-observation-fixture.ts'; import { platformResourceCleanup } from '../../platform-runtime-resource-cleanup.ts'; +import { readDaemonProviderCredentials } from '../../provider-credential-fingerprint.ts'; const unavailable = Object.freeze({ available: false as const, @@ -220,15 +222,20 @@ export const unavailableBindExactDevice: BindExactDeviceRuntime = async ( ); export function createRequestHandler( - deps: Omit & - Partial>, + deps: Omit & + Partial>, ) { - const { deviceRuntimeGateway = unavailableDeviceRuntimeGateway, ...rest } = deps; + const { + deviceRuntimeGateway = unavailableDeviceRuntimeGateway, + providerCredentials = readDaemonProviderCredentials({}, path.dirname(deps.logPath)), + ...rest + } = deps; const handle = createProductionRequestHandler({ androidObservation: clearAndroidObservationFixture, platformResourceCleanup, ...rest, deviceRuntimeGateway, + providerCredentials, }); // #1802: stand in for the client that reads a replay script and sends its content, so router // cases keep naming a path while the daemon still sees only bundled sources. diff --git a/src/daemon/handlers/__tests__/lease.test.ts b/src/daemon/handlers/__tests__/lease.test.ts index 06d75da2ca..05290692fa 100644 --- a/src/daemon/handlers/__tests__/lease.test.ts +++ b/src/daemon/handlers/__tests__/lease.test.ts @@ -11,6 +11,10 @@ import { markRequestCanceled, registerRequestAbort, } from '@agent-device/host-kit/request'; +import { + providerCredentialFingerprint, + readDaemonProviderCredentials, +} from '../../../provider-credential-fingerprint.ts'; import { HUMAN_CONTROL_LEASE_REQUEST, HUMAN_CONTROL_SCOPE, @@ -329,3 +333,126 @@ test('a repeat allocation canceled by its requester releases the reused lease', assert.deepEqual(releasedSessions, [lease.leaseId]); assert.deepEqual(registry.listActiveLeases(), []); }); + +const LIMRUN_ATTACH_ENV = { + LIMRUN_API_KEY: 'lim-key', + LIM_IOS_INSTANCE_URL: 'https://region.limrun.example/v1/ios_x/api', + LIM_IOS_INSTANCE_TOKEN: 'ios-token', +}; +const BROWSERSTACK_ENV = { BROWSERSTACK_USERNAME: 'user', BROWSERSTACK_ACCESS_KEY: 'key-1' }; + +function providerAllocateRequest( + leaseProvider: string, + providerCredentialFingerprint: string | undefined, +): DaemonRequest { + const request = allocateRequest(); + return { + ...request, + meta: { + ...request.meta, + leaseProvider, + leaseBackend: 'ios-instance', + providerCredentialFingerprint, + }, + }; +} + +async function allocateWithDaemonEnv( + req: DaemonRequest, + daemonEnv: Record, +): Promise<{ allocations: number; error?: AppError }> { + const registry = new LeaseRegistry(); + let allocations = 0; + try { + await handleLeaseCommands({ + req, + sessionName: req.session, + sessionStore: makeSessionStore('agent-device-provider-credentials-'), + leaseRegistry: registry, + providerCredentials: readDaemonProviderCredentials(daemonEnv, '/tmp/agent device state'), + leaseLifecycleProvider: { + allocate: async () => { + allocations += 1; + return {}; + }, + }, + }); + } catch (error) { + assert.deepEqual(registry.listActiveLeases(), []); + return { allocations, error: error as AppError }; + } + return { allocations }; +} + +test('a daemon started with only LIMRUN_API_KEY refuses a shell with instance variables before allocation', async () => { + const outcome = await allocateWithDaemonEnv( + providerAllocateRequest('limrun', providerCredentialFingerprint('limrun', LIMRUN_ATTACH_ENV)), + { LIMRUN_API_KEY: 'lim-key' }, + ); + + assert.equal(outcome.allocations, 0); + assert.equal(outcome.error?.code, 'INVALID_ARGS'); + assert.equal(outcome.error?.details?.reason, 'provider-credentials-changed'); + assert.equal(outcome.error?.details?.provider, 'limrun'); + assert.match( + String(outcome.error?.details?.hint), + /agent-device daemon stop --state-dir '\/tmp\/agent device state'/, + ); +}); + +test('a daemon holding rotated BrowserStack keys refuses before allocation', async () => { + const outcome = await allocateWithDaemonEnv( + providerAllocateRequest( + 'browserstack', + providerCredentialFingerprint('browserstack', { + ...BROWSERSTACK_ENV, + BROWSERSTACK_ACCESS_KEY: 'key-2', + }), + ), + BROWSERSTACK_ENV, + ); + + assert.equal(outcome.allocations, 0); + assert.equal(outcome.error?.details?.reason, 'provider-credentials-changed'); + assert.equal(outcome.error?.details?.provider, 'browserstack'); +}); + +test.for([ + ['limrun', LIMRUN_ATTACH_ENV], + ['browserstack', BROWSERSTACK_ENV], +] as const)( + 'a daemon holding the %s credentials of the shell allocates', + async ([provider, env]) => { + const outcome = await allocateWithDaemonEnv( + providerAllocateRequest(provider, providerCredentialFingerprint(provider, env)), + env, + ); + + assert.equal(outcome.error, undefined); + assert.equal(outcome.allocations, 1); + }, +); + +test('a daemon holding credentials allocates for a shell with none', async () => { + const outcome = await allocateWithDaemonEnv( + providerAllocateRequest('limrun', providerCredentialFingerprint('limrun', {})), + { LIMRUN_API_KEY: 'lim-key' }, + ); + + assert.equal(outcome.error, undefined); + assert.equal(outcome.allocations, 1); +}); + +test('a daemon started without BrowserStack credentials refuses a shell that has them', async () => { + const outcome = await allocateWithDaemonEnv( + providerAllocateRequest( + 'browserstack', + providerCredentialFingerprint('browserstack', BROWSERSTACK_ENV), + ), + {}, + ); + + assert.equal(outcome.allocations, 0); + assert.equal(outcome.error?.details?.reason, 'provider-credentials-changed'); + assert.match(String(outcome.error?.message), /started without the browserstack credentials/); +}); diff --git a/src/daemon/handlers/lease.ts b/src/daemon/handlers/lease.ts index 542f1db1d5..f04ad088d4 100644 --- a/src/daemon/handlers/lease.ts +++ b/src/daemon/handlers/lease.ts @@ -27,6 +27,8 @@ import { LEASE_ALLOCATION_BUDGET_MS } from '@agent-device/command-registry/timeo import { getRequestSignal, isRequestCanceled } from '@agent-device/host-kit/request'; import { listDownloadableArtifacts } from '../artifact-tracking.ts'; import { providerSessionIdFromData } from '../provider-session-ownership.ts'; +import type { DaemonProviderCredentials } from '../../provider-credential-fingerprint.ts'; +import { shellQuoteIfNeeded } from '@agent-device/kernel/device-shell'; type LeaseHandlerArgs = { req: DaemonRequest; @@ -35,6 +37,7 @@ type LeaseHandlerArgs = { leaseRegistry: LeaseRegistry; providerRuntimeIds?: readonly string[]; providerRuntimeRequiredIds?: readonly string[]; + providerCredentials?: DaemonProviderCredentials; leaseLifecycleProvider?: LeaseLifecycleProvider; cloudArtifactProvider?: CloudArtifactProvider; }; @@ -47,6 +50,7 @@ export async function handleLeaseCommands(args: LeaseHandlerArgs): Promise entry.leaseId), ); @@ -286,6 +295,27 @@ function assertProviderRuntimeAvailable( ); } +function assertProviderCredentialsUnchanged( + provider: string | undefined, + requested: string | undefined, + daemon: DaemonProviderCredentials | undefined, +): void { + if (!daemon || !provider || !requested) return; + const current = daemon.fingerprints[provider]; + if (requested === current) return; + throw new AppError( + 'INVALID_ARGS', + current + ? `The running daemon holds different ${provider} credentials than this shell.` + : `The running daemon was started without the ${provider} credentials this shell holds.`, + { + reason: 'provider-credentials-changed', + provider, + hint: `Stop it with agent-device daemon stop --state-dir ${shellQuoteIfNeeded(daemon.stateDir)}, then rerun the command so a new daemon starts with this shell's environment.`, + }, + ); +} + async function listArtifactsForRequest( req: DaemonRequest, leaseScope: ReturnType, diff --git a/src/daemon/request-handler-chain.ts b/src/daemon/request-handler-chain.ts index 8884506f2a..9b976611dc 100644 --- a/src/daemon/request-handler-chain.ts +++ b/src/daemon/request-handler-chain.ts @@ -24,6 +24,7 @@ import type { PlatformRequestScope } from '@agent-device/contracts/platform-runt import type { RequestPlatformProviderScope } from '@agent-device/contracts/platform-providers'; import type { AndroidObservationAdapter } from '@agent-device/contracts/android-observation'; import type { PlatformResourceCleanup } from './platform-resource-cleanup.ts'; +import type { DaemonProviderCredentials } from '../provider-credential-fingerprint.ts'; type RequestHandlerChainParams = { req: DaemonRequest; @@ -33,6 +34,7 @@ type RequestHandlerChainParams = { leaseRegistry: LeaseRegistry; providerRuntimeIds?: readonly string[]; providerRuntimeRequiredIds?: readonly string[]; + providerCredentials?: DaemonProviderCredentials; leaseLifecycleProvider?: LeaseLifecycleProvider; cloudArtifactProvider?: CloudArtifactProvider; providerAppCatalog?: ProviderAppCatalog; @@ -150,6 +152,7 @@ async function runLeaseHandler( leaseRegistry: params.leaseRegistry, providerRuntimeIds: params.providerRuntimeIds, providerRuntimeRequiredIds: params.providerRuntimeRequiredIds, + providerCredentials: params.providerCredentials, leaseLifecycleProvider: params.leaseLifecycleProvider, cloudArtifactProvider: params.cloudArtifactProvider, }), diff --git a/src/daemon/request-router.ts b/src/daemon/request-router.ts index 2d748f3be9..d0c46c1e98 100644 --- a/src/daemon/request-router.ts +++ b/src/daemon/request-router.ts @@ -84,6 +84,7 @@ import { discloseRequestDispatch, refusedBeforeDispatch } from './request-dispat import { recordNestedRequests } from './request-dispatch-ledger.ts'; import type { AndroidObservationAdapter } from '@agent-device/contracts/android-observation'; import type { PlatformResourceCleanup } from './platform-resource-cleanup.ts'; +import type { DaemonProviderCredentials } from '../provider-credential-fingerprint.ts'; import { restrictDeviceInventoryToDaemonPolicy } from './daemon-policy.ts'; import type { DaemonPolicy } from '../daemon-policy-file.ts'; @@ -106,6 +107,7 @@ export type RequestRouterDeps = { hostDiagnostics?: HostDiagnostics; providerRuntimeIds?: readonly string[]; providerRuntimeRequiredIds?: readonly string[]; + providerCredentials: DaemonProviderCredentials; leaseLifecycleProvider?: LeaseLifecycleProvider; cloudArtifactProvider?: CloudArtifactProvider; providerAppCatalog?: ProviderAppCatalog; @@ -163,6 +165,7 @@ export function createRequestHandler(deps: RequestRouterDeps): DaemonInvokeFn { hostDiagnostics, providerRuntimeIds, providerRuntimeRequiredIds, + providerCredentials, leaseLifecycleProvider, cloudArtifactProvider, providerAppCatalog, @@ -328,6 +331,7 @@ export function createRequestHandler(deps: RequestRouterDeps): DaemonInvokeFn { leaseLifecycleProvider, providerRuntimeIds, providerRuntimeRequiredIds, + providerCredentials, cloudArtifactProvider, providerAppCatalog, invoke: recordNestedRequests(handleRequest, dispatchLedger), diff --git a/src/daemon/server/daemon-runtime-interactor-composition.test.ts b/src/daemon/server/daemon-runtime-interactor-composition.test.ts index c191012ef0..0d4f923b33 100644 --- a/src/daemon/server/daemon-runtime-interactor-composition.test.ts +++ b/src/daemon/server/daemon-runtime-interactor-composition.test.ts @@ -6,6 +6,7 @@ import { setActiveProviderDeviceRuntimes } from '../../provider-device-runtime.t import { IOS_SIMULATOR } from '../../__tests__/test-utils/device-fixtures.ts'; import { mkdtempForTestSync } from '../../__tests__/test-utils/tmp-dir.ts'; import { createDaemonProviderRuntimeComposition } from '../../provider-device-runtimes.ts'; +import { providerCredentialFingerprint } from '../../provider-credential-fingerprint.ts'; import { DAEMON_STARTUP_EXIT_CODES, tryAcquireDaemonRegistration, @@ -89,6 +90,55 @@ test('daemon startup composes the interactor resolution the daemon resolves thro } }); +test('daemon startup compares lease credentials with its own startup environment', async () => { + const stateDir = mkdtempForTestSync('agent-device-daemon-credential-composition-'); + const daemonEnv = { BROWSERSTACK_USERNAME: 'user', BROWSERSTACK_ACCESS_KEY: 'key-1' }; + const runtime = await startDaemonRuntime({ + env: { + ...process.env, + ...daemonEnv, + AGENT_DEVICE_STATE_DIR: stateDir, + AGENT_DEVICE_DAEMON_IDLE_TIMEOUT_MS: '0', + AGENT_DEVICE_DAEMON_SERVER_MODE: 'http', + }, + exit: () => {}, + registerProcessHandlers: false, + stderr: { write: () => {} }, + stdout: { write: () => {} }, + }); + try { + const { httpPort, token } = JSON.parse( + fs.readFileSync(resolveDaemonPaths(stateDir).infoPath, 'utf8'), + ) as { httpPort: number; token: string }; + const allocate = async (env: Record) => + await ( + await fetch(`http://127.0.0.1:${httpPort}/rpc`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'agent_device.lease.allocate', + params: { + token, + tenantId: 'tenant', + runId: 'run', + leaseProvider: 'browserstack', + providerCredentialFingerprint: providerCredentialFingerprint('browserstack', env), + }, + }), + }) + ).text(); + + expect(await allocate({ ...daemonEnv, BROWSERSTACK_ACCESS_KEY: 'key-2' })).toContain( + 'provider-credentials-changed', + ); + expect(await allocate(daemonEnv)).not.toContain('provider-credentials-changed'); + } finally { + await runtime?.shutdown(); + } +}); + test('a daemon attempt losing the lock shuts down every constructed provider', async () => { const stateDir = mkdtempForTestSync('daemon-held-lock-'); const held = await tryAcquireDaemonRegistration(resolveDaemonPaths(stateDir)); diff --git a/src/daemon/server/daemon-runtime.ts b/src/daemon/server/daemon-runtime.ts index 2513f2a992..d2e5a0fbb6 100644 --- a/src/daemon/server/daemon-runtime.ts +++ b/src/daemon/server/daemon-runtime.ts @@ -25,6 +25,7 @@ import { createDaemonProviderRuntimeComposition, DEFAULT_PROVIDER_RUNTIME_REQUIRED_IDS, } from '../../provider-device-runtimes.ts'; +import { readDaemonProviderCredentials } from '../../provider-credential-fingerprint.ts'; import { LeaseRegistry } from '../lease-registry.ts'; import { createExpiredProviderLeaseReleaser } from '../provider-lease-expiry.ts'; import { createRequestHandler } from '../request-router.ts'; @@ -451,6 +452,7 @@ export async function startDaemonRuntime( platformResourceCleanup, providerRuntimeIds: providerRuntimeProviders.providerRuntimeIds, providerRuntimeRequiredIds: providerRuntimeProviders.providerRuntimeRequiredIds, + providerCredentials: readDaemonProviderCredentials(env, baseDir), providerDeviceRuntimeScope: providerRuntimeProviders.providerDeviceRuntimeScope, trackDownloadableArtifact, daemonPolicy, diff --git a/src/daemon/server/http-server.ts b/src/daemon/server/http-server.ts index b0d78ba083..9cf02d8a96 100644 --- a/src/daemon/server/http-server.ts +++ b/src/daemon/server/http-server.ts @@ -138,8 +138,14 @@ function restrictRemoteHttpRequest( 'Invalid params: path install sources are disabled on the remote HTTP surface', ); } - // A developer dir is a host path whose tools the daemon would run, so only local callers set it. - const { developerDir: _developerDir, ...meta } = request.meta ?? {}; + // A developer dir is a host path whose tools the daemon would run, and a credential fingerprint + // would let a remote caller probe the daemon's credentials. A daemon with an auth hook serves + // remote callers and runs on its operator's credentials, so it treats every caller as remote. + const { + developerDir: _developerDir, + providerCredentialFingerprint: _providerCredentialFingerprint, + ...meta + } = request.meta ?? {}; return { ...request, ...(request.meta ? { meta } : {}), @@ -335,6 +341,10 @@ function toLeaseDaemonRequest( readStringParam(params, 'leaseProvider') ?? readStringParam(params, 'provider'), deviceKey: readStringParam(params, 'deviceKey'), clientId: readStringParam(params, 'clientId'), + providerCredentialFingerprint: + command === 'lease_allocate' + ? readStringParam(params, 'providerCredentialFingerprint') + : undefined, }, }; } diff --git a/src/provider-credential-fingerprint.test.ts b/src/provider-credential-fingerprint.test.ts new file mode 100644 index 0000000000..d977bd21e2 --- /dev/null +++ b/src/provider-credential-fingerprint.test.ts @@ -0,0 +1,79 @@ +import { expect, test } from 'vitest'; +import { + providerCredentialFingerprint, + readDaemonProviderCredentials, +} from './provider-credential-fingerprint.ts'; + +const BROWSERSTACK_ENV = { BROWSERSTACK_USERNAME: 'user', BROWSERSTACK_ACCESS_KEY: 'key-1' }; + +test.for([ + ['limrun', { LIMRUN_API_KEY: 'lim-key' }, { LIMRUN_API_KEY: 'lim-rotated' }], + [ + 'limrun', + { LIMRUN_API_KEY: 'lim-key' }, + { + LIMRUN_API_KEY: 'lim-key', + LIM_IOS_INSTANCE_URL: 'https://region.limrun.example/v1/ios_x/api', + LIM_IOS_INSTANCE_TOKEN: 'ios-token', + }, + ], + ['browserstack', BROWSERSTACK_ENV, { ...BROWSERSTACK_ENV, BROWSERSTACK_ACCESS_KEY: 'key-2' }], +] as const)('%s fingerprint changes with its credential variables', ([provider, before, after]) => { + const fingerprint = providerCredentialFingerprint(provider, before); + expect(fingerprint).toMatch(/^v1:[0-9a-f]{16}$/); + expect(providerCredentialFingerprint(provider, { ...before })).toBe(fingerprint); + expect(providerCredentialFingerprint(provider, after)).not.toBe(fingerprint); +}); + +test('a fingerprint ignores variables the provider does not read and blank values', () => { + const fingerprint = providerCredentialFingerprint('browserstack', BROWSERSTACK_ENV); + expect( + providerCredentialFingerprint('browserstack', { + ...BROWSERSTACK_ENV, + LIMRUN_API_KEY: 'lim-key', + BROWSERSTACK_WEBDRIVER_ENDPOINT: 'https://hub.example', + }), + ).toBe(fingerprint); +}); + +test.for(['limrun', 'browserstack'])( + 'neither a caller nor a daemon without %s credentials has a fingerprint', + (provider) => { + expect(providerCredentialFingerprint(provider, {})).toBe(undefined); + expect(providerCredentialFingerprint(provider, { LIMRUN_REGION: ' ' })).toBe(undefined); + expect(readDaemonProviderCredentials({}, '/state').fingerprints[provider]).toBe(undefined); + }, +); + +test('a fingerprint hashes the exact values each provider reads', () => { + const browserstack = providerCredentialFingerprint('browserstack', BROWSERSTACK_ENV); + expect( + providerCredentialFingerprint('browserstack', { + ...BROWSERSTACK_ENV, + BROWSERSTACK_ACCESS_KEY: 'key-1 ', + }), + ).not.toBe(browserstack); + const limrun = providerCredentialFingerprint('limrun', { LIMRUN_API_KEY: 'lim-key' }); + expect(providerCredentialFingerprint('limrun', { LIMRUN_API_KEY: ' lim-key ' })).toBe(limrun); +}); + +test('a provider name that only matches an inherited object key has no fingerprint', () => { + expect(providerCredentialFingerprint('constructor', BROWSERSTACK_ENV)).toBe(undefined); +}); + +test('AWS Device Farm has no environment fingerprint', () => { + expect(providerCredentialFingerprint('aws-device-farm', { AWS_ACCESS_KEY_ID: 'id' })).toBe( + undefined, + ); + expect(Object.keys(readDaemonProviderCredentials({}, '/state').fingerprints).sort()).toEqual([ + 'browserstack', + 'limrun', + ]); +}); + +test('a fingerprint never contains a credential value', () => { + const fingerprints = JSON.stringify( + readDaemonProviderCredentials({ ...BROWSERSTACK_ENV, LIMRUN_API_KEY: 'lim-key' }, '/state'), + ); + for (const value of ['user', 'key-1', 'lim-key']) expect(fingerprints).not.toContain(value); +}); diff --git a/src/provider-credential-fingerprint.ts b/src/provider-credential-fingerprint.ts new file mode 100644 index 0000000000..e2ca0321e2 --- /dev/null +++ b/src/provider-credential-fingerprint.ts @@ -0,0 +1,67 @@ +import crypto from 'node:crypto'; +import { + BROWSERSTACK_CREDENTIAL_VARIABLES, + CLOUD_WEBDRIVER_PROVIDERS, + readBrowserStackCredentials, +} from '@agent-device/provider-webdriver/providers'; +import type { LIMRUN_PROVIDER } from '@agent-device/provider-limrun'; +import type { EnvMap } from '@agent-device/kernel/source-value'; +import { readLimrunCredentialValues } from './provider-limrun-credentials.ts'; + +type CredentialValues = Readonly>; + +// Each provider's own reader, so the fingerprint sees exactly the values the provider uses. AWS +// Device Farm is absent: it reads the AWS CLI credential chain, which no env hash identifies. +const PROVIDER_CREDENTIAL_READERS: ReadonlyMap CredentialValues> = new Map( + [ + ['limrun' satisfies typeof LIMRUN_PROVIDER, readLimrunCredentialValues], + [ + CLOUD_WEBDRIVER_PROVIDERS.browserStack, + (env: EnvMap): CredentialValues => { + const { username, accessKey } = readBrowserStackCredentials(env); + return { + [BROWSERSTACK_CREDENTIAL_VARIABLES.username]: username, + [BROWSERSTACK_CREDENTIAL_VARIABLES.accessKey]: accessKey, + }; + }, + ], + ], +); + +/** + * A versioned, non-reversible digest of the credentials a provider reads from `env`, or undefined + * when `env` holds none of them or the provider's credentials do not come from the environment. + */ +export function providerCredentialFingerprint(provider: string, env: EnvMap): string | undefined { + const read = PROVIDER_CREDENTIAL_READERS.get(provider); + return read ? digest(read(env)) : undefined; +} + +/** The provider credentials a daemon started with, and the state dir that names that daemon. */ +export type DaemonProviderCredentials = Readonly<{ + /** Undefined for a provider whose credentials the daemon's environment does not hold. */ + fingerprints: Readonly>; + stateDir: string; +}>; + +export function readDaemonProviderCredentials( + env: EnvMap, + stateDir: string, +): DaemonProviderCredentials { + const fingerprints = Object.fromEntries( + [...PROVIDER_CREDENTIAL_READERS.keys()].map((provider) => [ + provider, + providerCredentialFingerprint(provider, env), + ]), + ); + return { fingerprints, stateDir }; +} + +function digest(values: CredentialValues): string | undefined { + const pairs = Object.entries(values) + .filter((pair): pair is [string, string] => pair[1] !== undefined) + .sort(([left], [right]) => left.localeCompare(right)); + if (pairs.length === 0) return undefined; + const hash = crypto.createHash('sha256').update(JSON.stringify(pairs)).digest('hex'); + return `v1:${hash.slice(0, 16)}`; +} diff --git a/src/provider-limrun-credentials.ts b/src/provider-limrun-credentials.ts index 4fb205de0b..78c6c376f3 100644 --- a/src/provider-limrun-credentials.ts +++ b/src/provider-limrun-credentials.ts @@ -9,6 +9,8 @@ export type LimrunCredentials = Readonly<{ instances?: LimrunInstanceAccess; }>; +const ACCOUNT_VARS = { apiKey: 'LIMRUN_API_KEY', region: 'LIMRUN_REGION' } as const; + const INSTANCE_VARS = { ios: ['LIM_IOS_INSTANCE_URL', 'LIM_IOS_INSTANCE_TOKEN'], android: [ @@ -18,6 +20,22 @@ const INSTANCE_VARS = { ], } as const; +/** Every variable that selects which Limrun account or instance the credentials reach. */ +export const LIMRUN_CREDENTIAL_VARIABLES: readonly string[] = [ + ...Object.values(ACCOUNT_VARS), + ...INSTANCE_VARS.ios, + ...INSTANCE_VARS.android, +]; + +/** Each credential variable's value, read by the same rule the credential reader uses. */ +export function readLimrunCredentialValues( + env: EnvMap, +): Readonly> { + return Object.fromEntries( + LIMRUN_CREDENTIAL_VARIABLES.map((name) => [name, readValue(env, name)]), + ); +} + /** The variables that give access to an existing instance of a platform. */ export function limrunInstanceVariables(platform: 'ios' | 'android'): readonly string[] { return INSTANCE_VARS[platform]; @@ -28,8 +46,8 @@ export function limrunInstanceVariables(platform: 'ios' | 'android'): readonly s * names, so an orchestrator hands a sandbox one set of variables for both tools. */ export function readLimrunCredentials(env: EnvMap): LimrunCredentials | undefined { - const apiKey = env.LIMRUN_API_KEY?.trim() || undefined; - const region = env.LIMRUN_REGION?.trim() || undefined; + const apiKey = readValue(env, ACCOUNT_VARS.apiKey); + const region = readValue(env, ACCOUNT_VARS.region); const keepAlive = ['1', 'true'].includes(env.LIMRUN_KEEP_ALIVE?.trim().toLowerCase() ?? ''); const ios = readInstanceVars(env, INSTANCE_VARS.ios); const android = readInstanceVars(env, INSTANCE_VARS.android); @@ -49,7 +67,7 @@ function readInstanceVars( env: EnvMap, names: readonly Name[], ): Readonly> | undefined { - const entries = names.map((name) => [name, env[name]?.trim() || undefined] as const); + const entries = names.map((name) => [name, readValue(env, name)] as const); if (entries.every(([, value]) => value === undefined)) return undefined; const missing = entries.filter(([, value]) => value === undefined).map(([name]) => name); if (missing.length > 0) { @@ -59,3 +77,7 @@ function readInstanceVars( } return Object.fromEntries(entries) as Record; } + +function readValue(env: EnvMap, name: string): string | undefined { + return env[name]?.trim() || undefined; +} diff --git a/test/integration/provider-scenarios/harness.ts b/test/integration/provider-scenarios/harness.ts index 34c024dba6..3f62a2653f 100644 --- a/test/integration/provider-scenarios/harness.ts +++ b/test/integration/provider-scenarios/harness.ts @@ -17,6 +17,7 @@ import { type PlatformProviderResolvers, } from '../../../src/platform-runtime.ts'; import { platformResourceCleanup } from '../../../src/platform-runtime-resource-cleanup.ts'; +import { readDaemonProviderCredentials } from '../../../src/provider-credential-fingerprint.ts'; import type { AppleSimulatorScreenRecordingProcess } from '../../../src/platform-runtime-screen-recording-apple-transport.ts'; import { trackDownloadableArtifact } from '../../../src/daemon/artifact-tracking.ts'; import { LeaseRegistry } from '../../../src/daemon/lease-registry.ts'; @@ -137,6 +138,7 @@ export async function createProviderScenarioHarness( vegaToolProvider, webProvider, appleSimulatorScreenRecordingTransport, + providerCredentials = readDaemonProviderCredentials({}, path.dirname(sessionDir)), ...routerDeps } = deps; const platformRuntimeOptions = @@ -196,6 +198,7 @@ export async function createProviderScenarioHarness( ownedProcessRecords, }, }), + providerCredentials, ...routerDeps, }); const handleRequest: typeof requestHandler = async (request) => { diff --git a/test/wire-compat/ledger.json b/test/wire-compat/ledger.json index 2a7829226c..1cc9fa5467 100644 --- a/test/wire-compat/ledger.json +++ b/test/wire-compat/ledger.json @@ -56,7 +56,7 @@ "src/daemon-client/daemon-client-progress.ts#createInvalidDaemonResponseError": "sha256:0af812346b667a23fdfef641b242cd86d07edaa05eb928c4c531628c09a4031b", "src/daemon-client/daemon-client-progress.ts#shouldReadDaemonProgressStream": "sha256:6811404f41d8db3fa6fc751332c7186a2399ec7ecbc4e9c547d9cb9f14dbb350", "src/daemon-client/daemon-client-rpc.ts#appErrorFromDaemonError": "sha256:ac4761006c71d93ccba9f8e37cbd4cf48283dbb22737c163dc9c1a878e154eea", - "src/daemon-client/daemon-client-rpc.ts#buildHttpRpcPayload": "sha256:caddf184cd57a41d9910c414bba72ce5a0503e22a2a4ef3a47f95fed67037127", + "src/daemon-client/daemon-client-rpc.ts#buildHttpRpcPayload": "sha256:a8311d02126c51c0205be29d5deae68298b5dca223851a68f03aac95a4add5c6", "src/daemon-client/daemon-client-rpc.ts#buildLeaseRpcParams": "sha256:1755f46be8c62e7a8eb409e9a94d61781c96ccbef3306702121aa94264d63267", "src/daemon-client/daemon-client-rpc.ts#handleDaemonHttpResponseBody": "sha256:8f1c4bba1918545db6296ad29b95f0baa9f949e7c23bc04d59e261130f14611e", "src/daemon-client/daemon-client-rpc.ts#isLeaseRpcCommand": "sha256:955dc1b73462d40593e5728dbcbaeec1307944b08262a22627e84cc5dc507e8f", @@ -197,7 +197,7 @@ { "declaration": "packages/kernel/src/contracts.ts#DaemonRequestMeta", "digest": "sha256:668e635f88f651f6cebeb5f6505976729e598c0be35327ad00f314f151cb601c", - "rationale": "Adds the optional developerDir field: a local client's DEVELOPER_DIR, overlaid on the env of commands the daemon spawns for that request. A released daemon ignores the unknown meta key and keeps its own environment, exactly as before; a released client never sends it, so the new daemon inherits its own environment for that request. The remote HTTP surface strips it. Also adds the optional leaseRetainOnClose field, sent only on lease_allocate when the caller opts in: a released daemon ignores the unknown meta key and releases the lease on session close as before, and a released client never sends it." + "rationale": "Adds the optional developerDir field: a local client's DEVELOPER_DIR, overlaid on the env of commands the daemon spawns for that request. A released daemon ignores the unknown meta key and keeps its own environment, exactly as before; a released client never sends it, so the new daemon inherits its own environment for that request. The remote HTTP surface strips it. Also adds the optional leaseRetainOnClose field, sent only on lease_allocate when the caller opts in: a released daemon ignores the unknown meta key and releases the lease on session close as before, and a released client never sends it. Adds the optional providerCredentialFingerprint field: a digest of the local caller's provider credential variables, which the daemon compares with its own on lease_allocate. A released daemon ignores the unknown meta key and allocates as before; a released client never sends it, so the new daemon skips the comparison. The client sends it only to a local daemon, and the remote HTTP surface strips it." }, { "declaration": "packages/kernel/src/contracts.ts#DaemonArtifactKnownType", @@ -221,13 +221,13 @@ }, { "declaration": "src/daemon-client/daemon-client-rpc.ts#buildHttpRpcPayload", - "digest": "sha256:caddf184cd57a41d9910c414bba72ce5a0503e22a2a4ef3a47f95fed67037127", - "rationale": "#2110 added the optional providerApp parameter to lease allocation; #2494 extends the same additive projection to the full provider-allocation flag set the lease-lifecycle provider reads in prepareSession — device selection (platform, device), the app/os, the session-naming fields, and the configured device-feature and AWS knobs — so a fresh remote allocation reaches BrowserStack/AWS with a named, configured session instead of failing on a missing --device/--platform or landing in Untitled Project/Build. Producer and consumer both read one shared projection (readLeaseAllocateProviderFlags). Every field is optional and forwarded only when the caller supplied it: a released protocol-2 daemon ignores unknown JSON-RPC parameters, and a request carrying none of these retains the previous payload byte-for-byte." + "digest": "sha256:a8311d02126c51c0205be29d5deae68298b5dca223851a68f03aac95a4add5c6", + "rationale": "Adds the optional providerCredentialFingerprint parameter to agent_device.lease.allocate, forwarded only when the request meta carries one, which the client sets only for a local daemon. A released protocol-2 daemon ignores unknown JSON-RPC parameters, and a request without a fingerprint keeps the previous payload byte-for-byte." }, { "declaration": "src/daemon/server/http-server.ts#toLeaseDaemonRequest", "digest": "sha256:33052eb8a6521febee30a99d6c281e72fcaf3e7515188f7c926097dfc891d948", - "rationale": "#2110 read the optional providerApp lease-allocation parameter into the existing flags bag; #2494 reads the full provider-allocation flag set into that same bag through the shared readLeaseAllocateProviderFlags projection, so the reconstructed req.flags handed to prepareSession match what the line transport forwards for free. Released protocol-2 clients omit these and follow the unchanged allocation path; no existing field is required or reinterpreted. The optional retainOnClose boolean parameter is read into meta.leaseRetainOnClose; a released daemon ignores it and keeps releasing on session close." + "rationale": "#2110 read the optional providerApp lease-allocation parameter into the existing flags bag; #2494 reads the full provider-allocation flag set into that same bag through the shared readLeaseAllocateProviderFlags projection, so the reconstructed req.flags handed to prepareSession match what the line transport forwards for free. Released protocol-2 clients omit these and follow the unchanged allocation path; no existing field is required or reinterpreted. The optional retainOnClose boolean parameter is read into meta.leaseRetainOnClose; a released daemon ignores it and keeps releasing on session close. Reads the optional providerCredentialFingerprint lease-allocation parameter into request meta, so a local caller over HTTP gets the same credential check as over the socket. A daemon with an auth hook treats every HTTP caller as remote and strips it, as it does developerDir. Released protocol-2 clients omit it and follow the unchanged allocation path; no existing field is required or reinterpreted." }, { "declaration": "packages/kernel/src/errors.ts#DaemonError", diff --git a/website/docs/docs/browserstack.md b/website/docs/docs/browserstack.md index b70cb5b737..681ba53d8a 100644 --- a/website/docs/docs/browserstack.md +++ b/website/docs/docs/browserstack.md @@ -30,6 +30,8 @@ agent-device connect browserstack \ During `connect`, agent-device verifies the BrowserStack credentials and the exact device/OS pair. It checks a `bs://` reference against recent uploads and confirms that a local artifact exists before saving its absolute path. A public URL stays configured and BrowserStack validates it when the session starts. `open` still needs the app's installed package or bundle identifier, not its upload name. +A running daemon keeps the BrowserStack credentials it started with. If your shell holds different ones, the first command that allocates a lease, such as `open`, refuses before it creates a session; run `agent-device daemon stop` (with the same `--state-dir`) and rerun the command. A shell that sets neither variable uses the daemon's. + Optional labels: ```bash diff --git a/website/docs/docs/limrun.md b/website/docs/docs/limrun.md index 1af5ad2b8b..1f1a8b58c8 100644 --- a/website/docs/docs/limrun.md +++ b/website/docs/docs/limrun.md @@ -43,6 +43,8 @@ agent-device disconnect On an attached Android instance, agent-device does not replace an existing port reverse mapping. If the owner already maps a device port, such as `tcp:8081` for their Metro server, a reverse to that port fails and the owner's mapping stays in place. The error has `details.reason: 'android_port_reverse_rebind_refused'` when `adb reverse --list` shows the mapping. Otherwise it is a plain ADB failure. +A running daemon keeps the Limrun variables it started with. If your shell holds different ones, the first command that allocates a lease, such as `install` or `open`, refuses before it creates or attaches to an instance; run `agent-device daemon stop` (with the same `--state-dir`) and rerun the command. A shell that sets none of these variables uses the daemon's. + `install`, and `apps` before the first `open`, still need `LIMRUN_API_KEY`, because they use Limrun asset storage. After `open`, `apps` lists the apps installed on the instance without the key. Install the app before you hand over the instance. From the Node.js runtime, `getDeviceSession(device).installRemoteApp(url)` installs from a signed asset URL without the API key. ## Keeping idle sessions alive From 0ecc25351989c664632aee08099adf7c8f80071e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Mon, 5 Oct 2026 07:54:07 +0200 Subject: [PATCH 2/3] fix(providers): keep the connect verification error shape; scrub the HTTP auth hook in tests verifyBrowserStack reads credentials through the shared reader again but keeps its COMMAND_FAILED 'profile missed' error and reconnect hint. The hermetic test setup now also clears AGENT_DEVICE_HTTP_AUTH_HOOK and _EXPORT, so a host with a hook configured cannot turn a local daemon test into a remote one. --- src/__tests__/hermetic-env-setup.ts | 4 ++++ src/cli/connection/connect-provider-adapters.ts | 12 ++++++++++-- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/src/__tests__/hermetic-env-setup.ts b/src/__tests__/hermetic-env-setup.ts index 8cc114dac7..e017d06f41 100644 --- a/src/__tests__/hermetic-env-setup.ts +++ b/src/__tests__/hermetic-env-setup.ts @@ -15,6 +15,8 @@ import { afterEach } from 'vitest'; // daemonBaseUrl/daemonAuthToken keys, and daemon-client tests take the remote // path ("Remote daemon is unavailable") instead of the local one they exercise. // +// An HTTP auth hook likewise makes a local daemon treat every HTTP caller as remote. +// // CI runs with these unset. Delete them here so a configured host matches CI. // Tests that genuinely need them assign their own value or pass an explicit env // object; that happens inside the test, after this module has loaded, so this @@ -22,6 +24,8 @@ import { afterEach } from 'vitest'; const AMBIENT_DAEMON_ENV_VARS = [ 'AGENT_DEVICE_DAEMON_BASE_URL', 'AGENT_DEVICE_DAEMON_AUTH_TOKEN', + 'AGENT_DEVICE_HTTP_AUTH_HOOK', + 'AGENT_DEVICE_HTTP_AUTH_EXPORT', ] as const; for (const name of AMBIENT_DAEMON_ENV_VARS) { diff --git a/src/cli/connection/connect-provider-adapters.ts b/src/cli/connection/connect-provider-adapters.ts index 2e22a4d7f8..28ca37743e 100644 --- a/src/cli/connection/connect-provider-adapters.ts +++ b/src/cli/connection/connect-provider-adapters.ts @@ -2,7 +2,7 @@ import type { CliFlags } from '@agent-device/contracts/command'; import type { ProviderConnectionVerification } from '@agent-device/contracts/remote'; import { verifyLimrunConnection } from '@agent-device/provider-limrun'; import { AppError } from '@agent-device/kernel/errors'; -import { requireBrowserStackCredentials } from '@agent-device/provider-webdriver'; +import { readBrowserStackCredentials } from '@agent-device/provider-webdriver/providers'; import { providerWebDriver } from '../../provider-webdriver.ts'; import { resolveRemoteConfigProfile } from '../../remote/remote-config.ts'; import { readVersion } from '@agent-device/host-kit/version'; @@ -135,9 +135,17 @@ async function verifyBrowserStack( context: Pick, ): Promise { const { flags, env } = context; + const credentials = readBrowserStackCredentials(env); return await providerWebDriver.verifyConnection({ provider: 'browserstack', - ...requireBrowserStackCredentials(env, 'BrowserStack'), + username: requiredResolvedValue( + credentials.username, + 'BrowserStack profile missed BROWSERSTACK_USERNAME.', + ), + accessKey: requiredResolvedValue( + credentials.accessKey, + 'BrowserStack profile missed BROWSERSTACK_ACCESS_KEY.', + ), platform: requiredResolvedPlatform(flags.platform, 'BrowserStack'), deviceName: requiredResolvedValue(flags.device, 'BrowserStack profile missed device.'), osVersion: requiredResolvedValue( From 3fccd8615cbfd3cb0d7791cf7cbdc97ce0cbcfdb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Mon, 5 Oct 2026 13:13:47 +0200 Subject: [PATCH 3/3] chore(gates): record the combined lease wire digests after #3208 DaemonRequestMeta and toLeaseDaemonRequest now carry both the retainOnClose fields from #3208 and providerCredentialFingerprint; their acks keep both rationales and the digest of the merged declarations. --- test/wire-compat/ledger.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/test/wire-compat/ledger.json b/test/wire-compat/ledger.json index 1cc9fa5467..45e723cad2 100644 --- a/test/wire-compat/ledger.json +++ b/test/wire-compat/ledger.json @@ -21,7 +21,7 @@ "packages/kernel/src/contracts.ts#DaemonInstallSource": "sha256:fe26e202e62d997cdd3855fd96002511783a6c26a9ce27210d7c6cc084c66e4e", "packages/kernel/src/contracts.ts#DaemonLockPolicy": "sha256:f33d0ce7200a7d97c11e91bd2889b5d9bc7f35935a5370a29930118d091468f4", "packages/kernel/src/contracts.ts#DaemonRequest": "sha256:911d93245b8dc557db897c4cac051030db673711bf3af51baa8b82248d509c57", - "packages/kernel/src/contracts.ts#DaemonRequestMeta": "sha256:668e635f88f651f6cebeb5f6505976729e598c0be35327ad00f314f151cb601c", + "packages/kernel/src/contracts.ts#DaemonRequestMeta": "sha256:95c8aabe103b00af574ab5e97762fb295e44f1f5b17b69b11b5493cc56e4c1a1", "packages/kernel/src/contracts.ts#DaemonResponse": "sha256:5f6cc08ed43f40c1cf17e33dd2b6b879dda1ac383de9c6c9a2c653929c21346b", "packages/kernel/src/contracts.ts#DaemonResponseData": "sha256:f16e61b7f0ea8cdae82349508383af2f97a4c16c8aa21952d833094673f86afc", "packages/kernel/src/contracts.ts#JsonRpcId": "sha256:5a5fdff1cd85971214117d69a8278d0ecaf21a6fa3f4582155d9b3d9cad1fbd0", @@ -121,7 +121,7 @@ "src/daemon/server/http-server.ts#sendJson": "sha256:a1de00da3f1db8c98e69a85dfe2e11b626a4d2ce472840e3b8f519aa8a207a8f", "src/daemon/server/http-server.ts#toDaemonRequest": "sha256:f3861d2d4f67f66e83800616926f6bbb0595de4ecbb0072b5b78971ebeb0705b", "src/daemon/server/http-server.ts#toInstallFromSourceDaemonRequest": "sha256:dff093758b433043816dfc1e2e9658078875e46b887fdc530fb9380158d629f5", - "src/daemon/server/http-server.ts#toLeaseDaemonRequest": "sha256:33052eb8a6521febee30a99d6c281e72fcaf3e7515188f7c926097dfc891d948", + "src/daemon/server/http-server.ts#toLeaseDaemonRequest": "sha256:3018984aeace4054767875f5eb6e6ad5d38bfcb71c15d1a56ccf9b53e0a419ec", "src/daemon/server/http-server.ts#toReleaseMaterializedPathsDaemonRequest": "sha256:708736ca03d6a3fc449bb408382dbd509e490de1f84ecc8771fd77d4adda0154", "src/daemon/server/http-server.ts#writeProgressEnvelope": "sha256:7be778b3902c72d7aa6282c16dc7106fdd64e99c0b5daf9f413ff5dd3fb044f1", "src/daemon/server/http-server.ts#writeRpcResponseEnvelope": "sha256:7a8155e9fcbb53485489728250a85109b5dab0d96cdd4aa8b7e7eb87a4898ddb", @@ -196,7 +196,7 @@ "compatibleChanges": [ { "declaration": "packages/kernel/src/contracts.ts#DaemonRequestMeta", - "digest": "sha256:668e635f88f651f6cebeb5f6505976729e598c0be35327ad00f314f151cb601c", + "digest": "sha256:95c8aabe103b00af574ab5e97762fb295e44f1f5b17b69b11b5493cc56e4c1a1", "rationale": "Adds the optional developerDir field: a local client's DEVELOPER_DIR, overlaid on the env of commands the daemon spawns for that request. A released daemon ignores the unknown meta key and keeps its own environment, exactly as before; a released client never sends it, so the new daemon inherits its own environment for that request. The remote HTTP surface strips it. Also adds the optional leaseRetainOnClose field, sent only on lease_allocate when the caller opts in: a released daemon ignores the unknown meta key and releases the lease on session close as before, and a released client never sends it. Adds the optional providerCredentialFingerprint field: a digest of the local caller's provider credential variables, which the daemon compares with its own on lease_allocate. A released daemon ignores the unknown meta key and allocates as before; a released client never sends it, so the new daemon skips the comparison. The client sends it only to a local daemon, and the remote HTTP surface strips it." }, { @@ -226,7 +226,7 @@ }, { "declaration": "src/daemon/server/http-server.ts#toLeaseDaemonRequest", - "digest": "sha256:33052eb8a6521febee30a99d6c281e72fcaf3e7515188f7c926097dfc891d948", + "digest": "sha256:3018984aeace4054767875f5eb6e6ad5d38bfcb71c15d1a56ccf9b53e0a419ec", "rationale": "#2110 read the optional providerApp lease-allocation parameter into the existing flags bag; #2494 reads the full provider-allocation flag set into that same bag through the shared readLeaseAllocateProviderFlags projection, so the reconstructed req.flags handed to prepareSession match what the line transport forwards for free. Released protocol-2 clients omit these and follow the unchanged allocation path; no existing field is required or reinterpreted. The optional retainOnClose boolean parameter is read into meta.leaseRetainOnClose; a released daemon ignores it and keeps releasing on session close. Reads the optional providerCredentialFingerprint lease-allocation parameter into request meta, so a local caller over HTTP gets the same credential check as over the socket. A daemon with an auth hook treats every HTTP caller as remote and strips it, as it does developerDir. Released protocol-2 clients omit it and follow the unchanged allocation path; no existing field is required or reinterpreted." }, {