From 9524a5f50d4ce727505d3d42c9ff87c348465ff1 Mon Sep 17 00:00:00 2001 From: Adam Rowe Date: Mon, 2 Nov 2026 15:14:06 -0500 Subject: [PATCH 1/9] fix(daemon): refuse leased requests that name no session at admission The CLI already requires an explicit session on a leased remote connection, but a direct RPC or socket caller could still omit it and land in the shared default session. HTTP and socket intake now mark a missing or blank session on the daemon-only request envelope. Admission refuses a marked request that allocates a provider lease or carries a lease, with the existing REMOTE_SESSION_REQUIRED reason, before anything is allocated or dispatched. A provider named without a lease stays allowed, and an explicit session name, including "default", counts as named. Closes #4171 --- packages/contracts/src/lease-scope.ts | 13 ++ packages/daemon/src/core/daemon-request.ts | 6 + .../http-server-rpc-validation.test.ts | 2 + .../daemon-runtime-leased-session.test.ts | 174 ++++++++++++++++++ .../src/server/http/http-rpc-methods.ts | 13 +- .../daemon/src/server/http/transport.test.ts | 25 +++ packages/daemon/src/server/http/transport.ts | 7 +- .../src/server/request-execution-scope.ts | 2 + .../__tests__/request-admission.test.ts | 46 ++++- .../daemon/src/sessions/request-admission.ts | 22 ++- .../remote/src/remote-connection-state.ts | 14 +- src/cli.ts | 2 +- src/daemon-composition-credentials.test.ts | 1 + src/daemon-runtime-limrun-public-http.test.ts | 1 + .../remote-proxy-artifact-tenant.test.ts | 1 + test/wire-compat/ledger.json | 13 +- website/docs/docs/remote-proxy.md | 2 + 17 files changed, 321 insertions(+), 23 deletions(-) create mode 100644 packages/daemon/src/server/http/daemon-runtime-leased-session.test.ts diff --git a/packages/contracts/src/lease-scope.ts b/packages/contracts/src/lease-scope.ts index 3f6e2b9966..3db4af282f 100644 --- a/packages/contracts/src/lease-scope.ts +++ b/packages/contracts/src/lease-scope.ts @@ -94,6 +94,19 @@ type LeaseOptionsLike = { clientId?: string; }; +/** The refusal for a request on a provider lease that names no session. */ +export function remoteSessionRequiredError(leaseProvider: string | undefined): AppError { + return new AppError( + 'INVALID_ARGS', + 'A leased remote connection needs its session named: pass --session or set AGENT_DEVICE_SESSION.', + { + reason: 'REMOTE_SESSION_REQUIRED', + ...(leaseProvider === undefined ? {} : { leaseProvider }), + hint: "Without a name, a command could reach another agent's connection on this machine.", + }, + ); +} + export function leaseScopeFromRequest(req: LeaseRequestLike): LeaseScope { return stripUndefined({ tenantId: req.meta?.tenantId ?? readFlagString(req.flags, 'tenant'), diff --git a/packages/daemon/src/core/daemon-request.ts b/packages/daemon/src/core/daemon-request.ts index 8cd237cc37..7d0bbd1981 100644 --- a/packages/daemon/src/core/daemon-request.ts +++ b/packages/daemon/src/core/daemon-request.ts @@ -40,6 +40,12 @@ type DaemonRequestInternal = ReplayDispatchOptions & { */ retainDeviceExecutionLock?: (deviceId: string) => Promise; admittedLease?: DeviceLease; + /** + * The caller named no session and the transport that received the request filled in the default + * one. Set only at intake, because the defaulted name cannot be told apart from a chosen one + * afterwards. + */ + sessionOmitted?: true; /** * ADR 0014 / #1654: the complete ref/node/tree target a mutating `find` * resolved against its fresh capture. Its presence both marks the ref as diff --git a/packages/daemon/src/server/__tests__/http-server-rpc-validation.test.ts b/packages/daemon/src/server/__tests__/http-server-rpc-validation.test.ts index f8ef230be4..bb8003f9ca 100644 --- a/packages/daemon/src/server/__tests__/http-server-rpc-validation.test.ts +++ b/packages/daemon/src/server/__tests__/http-server-rpc-validation.test.ts @@ -126,6 +126,7 @@ test('the rpc boundary never accepts internal request fields from the wire', asy id: 'req-1', method: 'agent_device.command', params: { + session: 'agent-a', command: 'snapshot', positionals: [], internal: { @@ -339,6 +340,7 @@ test('local command RPC keeps host paths unrestricted', async (t) => { try { const port = await listenOnLoopback(server); const response = await postCommandRpc(port, { + session: 'agent-a', command: 'install_source', positionals: [], flags: { platform: 'android' }, diff --git a/packages/daemon/src/server/http/daemon-runtime-leased-session.test.ts b/packages/daemon/src/server/http/daemon-runtime-leased-session.test.ts new file mode 100644 index 0000000000..cf23dd70f3 --- /dev/null +++ b/packages/daemon/src/server/http/daemon-runtime-leased-session.test.ts @@ -0,0 +1,174 @@ +import fs from 'node:fs'; +import { afterEach, beforeEach, expect, test } from 'vitest'; +import { resolveDaemonPaths } from '@agent-device/daemon-contracts/daemon-resolution'; +import { createTestHostEnvironment, mkdtempForTestSync } from '@agent-device/test-kit'; +import { daemonRuntimeDepsFixture } from '../../__tests__/support/daemon-runtime-deps-fixture.ts'; +import { startDaemonRuntime } from './daemon-runtime.ts'; + +const TENANT = 'tenant-a'; +const RUN = 'run-1'; +const PROXY_SCOPE = { + tenantId: TENANT, + runId: RUN, + leaseProvider: 'proxy', + clientId: 'client-a', + deviceKey: 'android:mobile:emulator-5554', + backend: 'android-instance', +} as const; + +type RpcBody = { + result?: { ok?: boolean; data?: { lease?: { leaseId?: string } } }; + error?: { data?: { code?: string; details?: { reason?: string; leaseProvider?: string } } }; +}; + +let daemon: { close: () => Promise; httpPort: number; token: string }; + +beforeEach(async () => { + const stateDir = mkdtempForTestSync('agent-device-leased-session-'); + const runtime = await startDaemonRuntime({ + host: createTestHostEnvironment({ + env: { + ...process.env, + AGENT_DEVICE_STATE_DIR: stateDir, + AGENT_DEVICE_DAEMON_IDLE_TIMEOUT_MS: '0', + AGENT_DEVICE_DAEMON_SERVER_MODE: 'http', + }, + }), + stderr: { write: () => {} }, + stdout: { write: () => {} }, + composeDeps: daemonRuntimeDepsFixture({}), + }); + const { httpPort, token } = JSON.parse( + fs.readFileSync(resolveDaemonPaths(stateDir).infoPath, 'utf8'), + ) as { httpPort: number; token: string }; + daemon = { + close: async () => { + await runtime.close(); + }, + httpPort, + token, + }; +}); + +afterEach(async () => { + await daemon.close(); +}); + +async function rpc(method: string, params: Record): Promise { + const response = await fetch(`http://127.0.0.1:${daemon.httpPort}/rpc`, { + method: 'POST', + headers: { authorization: `Bearer ${daemon.token}`, 'content-type': 'application/json' }, + body: JSON.stringify({ jsonrpc: '2.0', id: method, method, params }), + }); + return (await response.json()) as RpcBody; +} + +function allocate(params: Record): Promise { + return rpc('agent_device.lease.allocate', params); +} + +async function allocateNamedLease(session: string): Promise { + const allocated = await allocate({ ...PROXY_SCOPE, session }); + const leaseId = allocated.result?.data?.lease?.leaseId; + expect(leaseId, JSON.stringify(allocated)).toBeTypeOf('string'); + return leaseId as string; +} + +function expectSessionRequired(body: RpcBody): void { + expect(body.error?.data).toMatchObject({ + code: 'INVALID_ARGS', + details: { reason: 'REMOTE_SESSION_REQUIRED', leaseProvider: 'proxy' }, + }); +} + +test.each([ + ['omitted', {}], + ['blank', { session: ' ' }], +])('a %s session is refused before a provider lease is allocated', async (_label, session) => { + expectSessionRequired(await allocate({ ...PROXY_SCOPE, ...session })); + + const named = await allocate({ ...PROXY_SCOPE, session: 'agent-a' }); + expect(named.result?.ok).toBe(true); +}); + +test('an unnamed heartbeat is refused and the named lease stays usable', async () => { + const leaseId = await allocateNamedLease('agent-a'); + + expectSessionRequired(await rpc('agent_device.lease.heartbeat', { ...PROXY_SCOPE, leaseId })); + + const named = await rpc('agent_device.lease.heartbeat', { + ...PROXY_SCOPE, + leaseId, + session: 'agent-a', + }); + expect(named.result?.ok).toBe(true); +}); + +test('an unnamed release is refused and the lease is still released when named', async () => { + const leaseId = await allocateNamedLease('agent-a'); + + expectSessionRequired(await rpc('agent_device.lease.release', { ...PROXY_SCOPE, leaseId })); + + const named = await rpc('agent_device.lease.release', { + ...PROXY_SCOPE, + leaseId, + session: 'agent-a', + }); + expect(named.result?.data).toMatchObject({ released: true }); +}); + +test.each([ + ['shared', {}], + ['tenant-isolated', { sessionIsolation: 'tenant' }], +])( + 'an unnamed leased command is refused before dispatch in a %s daemon', + async (_label, isolation) => { + const leaseId = await allocateNamedLease('agent-a'); + const { backend: leaseBackend, ...scope } = PROXY_SCOPE; + + const refused = await rpc('agent_device.command', { + command: 'snapshot', + positionals: [], + meta: { ...scope, leaseBackend, leaseId, ...isolation }, + }); + + expectSessionRequired(refused); + }, +); + +test('a named leased command is not refused for its session', async () => { + const leaseId = await allocateNamedLease('agent-a'); + const { backend: leaseBackend, ...scope } = PROXY_SCOPE; + + const named = await rpc('agent_device.command', { + session: 'agent-a', + command: 'snapshot', + positionals: [], + meta: { ...scope, leaseBackend, leaseId }, + }); + + expect(named.error?.data?.code, JSON.stringify(named)).toBeDefined(); + expect(named.error?.data?.details?.reason).not.toBe('REMOTE_SESSION_REQUIRED'); +}); + +test('an unnamed request that names a provider but holds no lease is not refused for its session', async () => { + const listed = await rpc('agent_device.command', { + command: 'apps', + positionals: [], + flags: { platform: 'android', leaseProvider: 'proxy' }, + }); + + expect(listed.error?.data?.details?.reason).not.toBe('REMOTE_SESSION_REQUIRED'); +}); + +test('requests with no lease provider keep their unnamed default session', async () => { + const allocated = await allocate({ + tenantId: TENANT, + runId: RUN, + backend: 'android-instance', + }); + expect(allocated.result?.ok, JSON.stringify(allocated)).toBe(true); + + const listed = await rpc('agent_device.command', { command: 'session_list' }); + expect(listed.result?.ok, JSON.stringify(listed)).toBe(true); +}); diff --git a/packages/daemon/src/server/http/http-rpc-methods.ts b/packages/daemon/src/server/http/http-rpc-methods.ts index 27c2486917..d051eeb519 100644 --- a/packages/daemon/src/server/http/http-rpc-methods.ts +++ b/packages/daemon/src/server/http/http-rpc-methods.ts @@ -42,10 +42,19 @@ const SUPPORTED_RPC_METHODS = new Set([ ...Object.keys(LEASE_RPC_METHOD_TO_COMMAND), ]); +function requestSessionFields( + session: string | undefined, +): Pick { + return { + session: session ?? 'default', + ...(session?.trim() ? {} : { internal: { sessionOmitted: true } }), + }; +} + function toDaemonRequest(params: CommandRpcParams, headers: IncomingHttpHeaders): DaemonRequest { return { token: resolveToken(params as Record, headers), - session: params.session ?? 'default', + ...requestSessionFields(params.session), command: params.command ?? '', positionals: params.positionals ?? [], input: params.input, @@ -148,7 +157,7 @@ function toLeaseDaemonRequest( ): DaemonRequest { return { token: resolveToken(params, headers), - session: readStringParam(params, 'session') ?? 'default', + ...requestSessionFields(readStringParam(params, 'session')), command, positionals: [], flags: command === 'lease_allocate' ? readLeaseAllocateProviderFlags(params) : undefined, diff --git a/packages/daemon/src/server/http/transport.test.ts b/packages/daemon/src/server/http/transport.test.ts index 509ba0850a..f43f64a545 100644 --- a/packages/daemon/src/server/http/transport.test.ts +++ b/packages/daemon/src/server/http/transport.test.ts @@ -86,6 +86,31 @@ test('Provider-backed integration daemon socket transport frames requests and no // A socket client that vanishes mid-request must cancel that connection's // in-flight request via its request-scoped signal — never through a global // Apple-runner abort, and never touching another connection's request. +test.each([ + ['omitted', undefined, true], + ['blank', ' ', true], + ['named', 'agent-a', undefined], +])('the socket records a session that is %s', async (_label, session, sessionOmitted) => { + const observedRequests: DaemonRequest[] = []; + const server = createSocketServer(async (req): Promise => { + observedRequests.push(req); + return { ok: true, data: {} }; + }); + + try { + const client = await connectSocket(await listenNetServer(server)); + await writeSocketRequests( + client, + [JSON.stringify({ token: 'token', session, command: 'session_list', positionals: [] })], + 1, + ); + client.destroy(); + assert.equal(observedRequests[0]?.internal?.sessionOmitted, sessionOmitted); + } finally { + await closeLoopbackServer(server); + } +}); + test('socket disconnect cancels only the disconnected connection in-flight request', async (t) => { if (await skipWhenLoopbackUnavailable(t)) return; diff --git a/packages/daemon/src/server/http/transport.ts b/packages/daemon/src/server/http/transport.ts index bb7a409bec..142aafffad 100644 --- a/packages/daemon/src/server/http/transport.ts +++ b/packages/daemon/src/server/http/transport.ts @@ -133,7 +133,12 @@ function parseSocketDaemonRequest(line: string): DaemonRequest { // forge those semantics even though the legacy socket request otherwise // preserves its existing raw wire shape. const { internal: _internal, ...request } = parsed as Record; - return request as DaemonRequest; + const session = request.session; + return ( + typeof session === 'string' && session.trim() + ? request + : { ...request, internal: { sessionOmitted: true } } + ) as DaemonRequest; } export function listenNetServer(server: net.Server): Promise { diff --git a/packages/daemon/src/server/request-execution-scope.ts b/packages/daemon/src/server/request-execution-scope.ts index 86f758e327..a560de058f 100644 --- a/packages/daemon/src/server/request-execution-scope.ts +++ b/packages/daemon/src/server/request-execution-scope.ts @@ -31,6 +31,7 @@ import { resolveEffectiveSessionName, } from '../sessions/session-routing.ts'; import { + assertProviderLeaseRequestNamesSession, assertRequestLeaseAdmissionPreflight, scopeRequestSession, } from '../sessions/request-admission.ts'; @@ -288,6 +289,7 @@ export async function createRequestExecutionScope(params: { } try { if (params.daemonPolicy) assertDaemonPolicyAdmitsRequest(params.daemonPolicy, scopedReq); + assertProviderLeaseRequestNamesSession(scopedReq, sessionStore.lookup(sessionName)?.session); assertRequestLeaseAdmissionPreflight(scopedReq); // Parse the budget once, before resolving the target device or taking any lock. The lock plan // still supplies the device to wait for, but an out-of-range budget is refused before either. diff --git a/packages/daemon/src/sessions/__tests__/request-admission.test.ts b/packages/daemon/src/sessions/__tests__/request-admission.test.ts index fd6d29803b..99993ddb4d 100644 --- a/packages/daemon/src/sessions/__tests__/request-admission.test.ts +++ b/packages/daemon/src/sessions/__tests__/request-admission.test.ts @@ -4,7 +4,11 @@ import { makeIosSession, makeMacOsSession } from '../../__tests__/support/sessio import { parseDaemonPolicy } from '@agent-device/daemon-contracts/daemon-policy-file'; import { AppError } from '@agent-device/kernel/errors'; import { LeaseRegistry } from '../../resources/lease-registry.ts'; -import { assertRequestLeaseAdmission, isConfinedToAppLease } from '../request-admission.ts'; +import { + assertProviderLeaseRequestNamesSession, + assertRequestLeaseAdmission, + isConfinedToAppLease, +} from '../request-admission.ts'; import type { DaemonRequest } from '../../core/daemon-request.ts'; import type { ProviderAppCatalog } from '@agent-device/contracts/device'; @@ -463,3 +467,43 @@ test('a daemon policy that requires a macos-app lease refuses unleased and other lease.leaseId, ); }); + +const SESSION_LEASE = { tenantId: 'tenant-a', runId: 'run-1', leaseId: 'a'.repeat(32) }; + +test('an unnamed request reaching a provider lease through its session is refused', () => { + const session = makeIosSession('default', { + lease: { ...SESSION_LEASE, leaseProvider: 'proxy' }, + }); + + assert.throws( + () => + assertProviderLeaseRequestNamesSession( + makeRequest({ command: 'snapshot', internal: { sessionOmitted: true } }), + session, + ), + (error: unknown) => { + assert.ok(error instanceof AppError); + assert.equal(error.code, 'INVALID_ARGS'); + assert.equal(error.details?.reason, 'REMOTE_SESSION_REQUIRED'); + assert.equal(error.details?.leaseProvider, 'proxy'); + return true; + }, + ); +}); + +test('a named request on the same provider-leased session is not refused', () => { + const session = makeIosSession('default', { + lease: { ...SESSION_LEASE, leaseProvider: 'proxy' }, + }); + + assertProviderLeaseRequestNamesSession(makeRequest({ command: 'snapshot' }), session); +}); + +test('an unnamed request on a session whose lease has no provider is not refused', () => { + const session = makeIosSession('default', { lease: SESSION_LEASE }); + + assertProviderLeaseRequestNamesSession( + makeRequest({ command: 'snapshot', internal: { sessionOmitted: true } }), + session, + ); +}); diff --git a/packages/daemon/src/sessions/request-admission.ts b/packages/daemon/src/sessions/request-admission.ts index b035adc70a..addba37cc7 100644 --- a/packages/daemon/src/sessions/request-admission.ts +++ b/packages/daemon/src/sessions/request-admission.ts @@ -15,7 +15,10 @@ import { resolveLeaseScope, resolveRequestOrSessionLeaseScope, } from '../core/lease-context.ts'; -import { leaseScopeToHeartbeatRequest } from '@agent-device/contracts/lease-scope'; +import { + leaseScopeToHeartbeatRequest, + remoteSessionRequiredError, +} from '@agent-device/contracts/lease-scope'; import type { LeaseRegistry } from '../resources/lease-registry.ts'; import type { DaemonRequest } from '../core/daemon-request.ts'; import type { SessionState } from '../core/session-state.ts'; @@ -168,6 +171,23 @@ function hasSessionlessLeaseAdmissionExemption( ); } +/** + * An unnamed session resolves to whichever connection holds the default name, so a request that + * would allocate, use or release a provider lease has to name the session it means. Naming a + * provider alone is not that: a sessionless catalog read holds no lease. A request that names only + * a lease id passes here too, because the registry's owner scope refuses it for omitting the + * provider. + */ +export function assertProviderLeaseRequestNamesSession( + req: DaemonRequest, + session: SessionState | undefined, +): void { + if (!req.internal?.sessionOmitted) return; + const { leaseProvider, leaseId } = resolveRequestOrSessionLeaseScope(req, session); + if (!leaseProvider) return; + if (leaseId || req.command === 'lease_allocate') throw remoteSessionRequiredError(leaseProvider); +} + export function assertRequestLeaseAdmissionPreflight(req: DaemonRequest): void { if (isLeaseAdmissionExempt(req.command)) return; assertProxyOpenLeaseMetadata(req, resolveLeaseScope(req)); diff --git a/packages/remote/src/remote-connection-state.ts b/packages/remote/src/remote-connection-state.ts index 93b993914d..4ae5bf23eb 100644 --- a/packages/remote/src/remote-connection-state.ts +++ b/packages/remote/src/remote-connection-state.ts @@ -30,6 +30,7 @@ import { leaseScopeToCommandFlags, leaseScopeToConnectionMetadata, parseLeaseDeviceKey, + remoteSessionRequiredError, } from '@agent-device/contracts/lease-scope'; export type RemoteConnectionState = { @@ -413,19 +414,6 @@ export function requireLeasedConnectionSession( throw remoteSessionRequiredError(connection.leaseProvider); } -/** The refusal for a command on a leased connection that names no session. */ -export function remoteSessionRequiredError(leaseProvider: string | undefined): AppError { - return new AppError( - 'INVALID_ARGS', - 'A leased remote connection needs its session named: pass --session or set AGENT_DEVICE_SESSION.', - { - reason: 'REMOTE_SESSION_REQUIRED', - ...(leaseProvider === undefined ? {} : { leaseProvider }), - hint: "Without a name, a command could reach another agent's connection on this machine.", - }, - ); -} - function isLeasedConnection(connection: Pick): boolean { return connection.leaseProvider !== undefined; } diff --git a/src/cli.ts b/src/cli.ts index b81c3a0e4a..182cf14f93 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -47,8 +47,8 @@ import { import { applyDefaultPlatformBinding, resolveBindingSettings } from './cli/session-binding.ts'; import { resolveCliOptions } from './cli/resolve-cli-options.ts'; import { maybeRunUpgradeNotifier } from './cli/update-check.ts'; +import { remoteSessionRequiredError } from '@agent-device/contracts/lease-scope'; import { - remoteSessionRequiredError, resolveRemoteConnectionDefaults, type RemoteConnectionRequestMetadata, } from '@agent-device/remote/remote-connection-state'; diff --git a/src/daemon-composition-credentials.test.ts b/src/daemon-composition-credentials.test.ts index 5609f0f392..535c94f4b6 100644 --- a/src/daemon-composition-credentials.test.ts +++ b/src/daemon-composition-credentials.test.ts @@ -84,6 +84,7 @@ test('daemon startup compares lease credentials with its own startup environment method: 'agent_device.lease.allocate', params: { token, + session: 'agent-a', tenantId: 'tenant', runId: 'run', leaseProvider: 'browserstack', diff --git a/src/daemon-runtime-limrun-public-http.test.ts b/src/daemon-runtime-limrun-public-http.test.ts index d18886091f..b0f75306f7 100644 --- a/src/daemon-runtime-limrun-public-http.test.ts +++ b/src/daemon-runtime-limrun-public-http.test.ts @@ -142,6 +142,7 @@ test('public HTTP rejects Limrun uploaded-app listing and allocation before prov id: 'limrun-public-allocation', method: 'agent_device.lease.allocate', params: { + session: 'agent-a', tenantId: 'tenant-a', runId: 'run-a', backend: 'android-instance', diff --git a/test/integration/provider-scenarios/remote-proxy-artifact-tenant.test.ts b/test/integration/provider-scenarios/remote-proxy-artifact-tenant.test.ts index afcd3dc2ac..660473ef1a 100644 --- a/test/integration/provider-scenarios/remote-proxy-artifact-tenant.test.ts +++ b/test/integration/provider-scenarios/remote-proxy-artifact-tenant.test.ts @@ -49,6 +49,7 @@ test('Provider-backed integration proxy materializes tenant-owned test artifacts id: 'tenant-artifact-lease', method: 'agent_device.lease.allocate', params: { + session: 'default', tenantId: TENANT, runId: 'artifact-run', backend: 'android-instance', diff --git a/test/wire-compat/ledger.json b/test/wire-compat/ledger.json index cb7b48b017..b05f6044c2 100644 --- a/test/wire-compat/ledger.json +++ b/test/wire-compat/ledger.json @@ -117,9 +117,9 @@ "packages/daemon/src/server/http/http-authorization.ts#readHeaderValue": "sha256:0ec16993e5d9b460ba02deb4a43bbc4c248f9697eaed9aa64003eec7d5dce6bf", "packages/daemon/src/server/http/http-authorization.ts#resolveToken": "sha256:3abcb019e1325317618045b7138e41845990af10953dd937571997cd6ec35981", "packages/daemon/src/server/http/http-rpc-envelope.ts#sendJson": "sha256:a1de00da3f1db8c98e69a85dfe2e11b626a4d2ce472840e3b8f519aa8a207a8f", - "packages/daemon/src/server/http/http-rpc-methods.ts#toDaemonRequest": "sha256:f3861d2d4f67f66e83800616926f6bbb0595de4ecbb0072b5b78971ebeb0705b", + "packages/daemon/src/server/http/http-rpc-methods.ts#toDaemonRequest": "sha256:458d1568bb9d3ca3ae6f6aac47e1721489526ddeb9b5ae345a7b3328d855ba47", "packages/daemon/src/server/http/http-rpc-methods.ts#toInstallFromSourceDaemonRequest": "sha256:dff093758b433043816dfc1e2e9658078875e46b887fdc530fb9380158d629f5", - "packages/daemon/src/server/http/http-rpc-methods.ts#toLeaseDaemonRequest": "sha256:3018984aeace4054767875f5eb6e6ad5d38bfcb71c15d1a56ccf9b53e0a419ec", + "packages/daemon/src/server/http/http-rpc-methods.ts#toLeaseDaemonRequest": "sha256:649db7b268ca9b8b95d1d2b7b22b349f5938711533347ab82a509f346da3aee8", "packages/daemon/src/server/http/http-rpc-methods.ts#toReleaseMaterializedPathsDaemonRequest": "sha256:708736ca03d6a3fc449bb408382dbd509e490de1f84ecc8771fd77d4adda0154", "packages/daemon/src/server/http/http-rpc-envelope.ts#writeProgressEnvelope": "sha256:7be778b3902c72d7aa6282c16dc7106fdd64e99c0b5daf9f413ff5dd3fb044f1", "packages/daemon/src/server/http/http-rpc-envelope.ts#writeRpcResponseEnvelope": "sha256:7a8155e9fcbb53485489728250a85109b5dab0d96cdd4aa8b7e7eb87a4898ddb", @@ -225,10 +225,15 @@ "digest": "sha256:8978ef89d3af866bd94fec52c586a0cbf3217ee9e60952fbc826e8208e5fa158", "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. The signature also names the client request as DaemonWireRequest and the response as the kernel DaemonResponse instead of the daemon package aliases; only compile-time type names moved. It now composes the envelope through buildDaemonRpcRequest in @agent-device/contracts/lease-scope, which takes the request id from its caller; the client supplies the id it supplied before (the request meta id, else a fresh one), so envelope construction preserves method, params and id. Separately, credential fingerprint lookup now trims the provider name on both client and daemon. A whitespace-spelled provider can therefore add a fingerprint that the released client omitted; this changes fingerprint presence, not the envelope or optional parameter contract." }, + { + "declaration": "packages/daemon/src/server/http/http-rpc-methods.ts#toDaemonRequest", + "digest": "sha256:458d1568bb9d3ca3ae6f6aac47e1721489526ddeb9b5ae345a7b3328d855ba47", + "rationale": "#4171 records a blank or omitted session in daemon-only request state while still defaulting its name. Released protocol-2 clients always send a session on this envelope, so their payload is read as before. Only a direct RPC caller that names no session on a provider lease changes: it gets a normal INVALID_ARGS error with reason REMOTE_SESSION_REQUIRED, and no peer misreads a payload." + }, { "declaration": "packages/daemon/src/server/http/http-rpc-methods.ts#toLeaseDaemonRequest", - "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." + "digest": "sha256:649db7b268ca9b8b95d1d2b7b22b349f5938711533347ab82a509f346da3aee8", + "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. #4171 records a blank or omitted session in daemon-only request state while still defaulting its name. Released protocol-2 clients always send a session on this envelope, so their payload is read as before. Only a direct RPC caller that names no session on a provider lease changes: it gets a normal INVALID_ARGS error with reason REMOTE_SESSION_REQUIRED, and no peer misreads a payload." }, { "declaration": "packages/kernel/src/errors.ts#DaemonError", diff --git a/website/docs/docs/remote-proxy.md b/website/docs/docs/remote-proxy.md index 43d3a764f6..ff67c4c65f 100644 --- a/website/docs/docs/remote-proxy.md +++ b/website/docs/docs/remote-proxy.md @@ -44,6 +44,8 @@ agent-device disconnect Every command on a proxy connection must name its session, with `AGENT_DEVICE_SESSION`, `--session `, or a `session` in your config. The same applies to every device-cloud connection. A command that names none fails with `INVALID_ARGS` and `details.reason: "REMOTE_SESSION_REQUIRED"`, and sends nothing to the proxy. It never falls back to the connection that another agent on the same machine saved last. This includes `connect proxy` and `open --remote-config ` with a proxy profile. The client and run that the proxy leases under come from the session name, so two agents with different names never share a lease. +The daemon enforces the same rule for direct RPC callers. An `agent_device.command` or `agent_device.lease.*` request with no `session` param that would allocate, use, or release a lease from a lease provider fails with the same reason before anything is allocated or dispatched. Requests with no lease provider keep their default session. + `--daemon-auth-token ` works instead of the environment variable, but it authenticates only the command you pass it to. Later commands need the token again through the environment variable, a `daemonAuthToken` entry in your remote config profile, or the flag on each command. `connect proxy` stores the proxy profile and client identity. `open` leases the device for you, and the lease expires after five minutes without commands. A lease allocated directly over the RPC without `ttlMs` gets the daemon's one-minute inactivity default instead. Either window starts when allocation completes, not when it was requested. `close` releases the active session and its device lease, unless the lease was allocated with `retainOnClose`; only `leases.release`, expiry, or daemon shutdown ends that one. The reply to any request that closed a session lists the leases it ended in `endedLeases`, as `{ leaseId, outcome: 'released' }`. This covers `close`, a `batch` with a close step, and a `replay` or `test` script that closes, as well as the session cleanup `test` runs after each script. A successful reply has the field in `data`, and a failed one has it in `error.details`. A gateway in front of the daemon reads the lease end from that reply. When the reply names the stored lease, the CLI forgets it, so the next `open` leases a device again. `disconnect` clears local connection state. From 2c7953cf47848529eee55538936e704d7eb53c90 Mon Sep 17 00:00:00 2001 From: Adam Rowe Date: Mon, 2 Nov 2026 15:28:46 -0500 Subject: [PATCH 2/9] fix(daemon): close the remaining unnamed lease paths at admission agent_device.install_from_source did not record an omitted session, so an unnamed install could reach the provider lease held by the default session. A batch step also lost the marker, so a step carrying lease flags passed admission. Lease-exempt commands such as session_list now read only a lease they carry themselves, so an unnamed inventory command still answers while the default session holds a provider lease. The refusal also names the session parameter for direct RPC callers, and the ledger now digests the shared session helper and describes blank names for released clients correctly. --- packages/contracts/src/lease-scope.ts | 4 +- .../src/core/__tests__/execution-plan.test.ts | 22 ++++ .../http-server-rpc-validation.test.ts | 42 +++++++ ...st-execution-scope-unnamed-session.test.ts | 110 ++++++++++++++++++ .../daemon-runtime-leased-session.test.ts | 9 +- .../src/server/http/http-rpc-methods.ts | 2 +- .../daemon/src/server/http/transport.test.ts | 51 ++++---- .../src/sessions/handlers/session-batch.ts | 1 + .../daemon/src/sessions/request-admission.ts | 7 +- test/wire-compat/ledger.json | 21 +++- test/wire-compat/surface.ts | 1 + 11 files changed, 233 insertions(+), 37 deletions(-) create mode 100644 packages/daemon/src/server/__tests__/request-execution-scope-unnamed-session.test.ts diff --git a/packages/contracts/src/lease-scope.ts b/packages/contracts/src/lease-scope.ts index 3db4af282f..66bd95cdf7 100644 --- a/packages/contracts/src/lease-scope.ts +++ b/packages/contracts/src/lease-scope.ts @@ -98,11 +98,11 @@ type LeaseOptionsLike = { export function remoteSessionRequiredError(leaseProvider: string | undefined): AppError { return new AppError( 'INVALID_ARGS', - 'A leased remote connection needs its session named: pass --session or set AGENT_DEVICE_SESSION.', + 'A leased remote connection needs its session named: pass --session , set AGENT_DEVICE_SESSION, or send the session parameter.', { reason: 'REMOTE_SESSION_REQUIRED', ...(leaseProvider === undefined ? {} : { leaseProvider }), - hint: "Without a name, a command could reach another agent's connection on this machine.", + hint: "Without a name, a command could reach another agent's connection.", }, ); } diff --git a/packages/daemon/src/core/__tests__/execution-plan.test.ts b/packages/daemon/src/core/__tests__/execution-plan.test.ts index 0a9481930d..191664daca 100644 --- a/packages/daemon/src/core/__tests__/execution-plan.test.ts +++ b/packages/daemon/src/core/__tests__/execution-plan.test.ts @@ -47,3 +47,25 @@ test('batch steps carry the steps still ahead of them through the internal chann { command: 'snapshot', remaining: [] }, ]); }); + +test.each([ + ['keep', { sessionOmitted: true } as const, true], + ['do not gain', undefined, undefined], +])('batch steps %s the marker for an unnamed session', async (_label, internal, sessionOmitted) => { + const seen: unknown[] = []; + const req: DaemonRequest = { + token: 'token', + session: 'default', + command: 'batch', + positionals: [], + flags: { batchSteps: [{ command: 'snapshot' }] }, + ...(internal ? { internal } : {}), + }; + + await runBatchCommands(req, 'default', async (stepRequest) => { + seen.push(stepRequest.internal?.sessionOmitted); + return { ok: true, data: {} }; + }); + + assert.deepEqual(seen, [sessionOmitted]); +}); diff --git a/packages/daemon/src/server/__tests__/http-server-rpc-validation.test.ts b/packages/daemon/src/server/__tests__/http-server-rpc-validation.test.ts index bb8003f9ca..64f546c11c 100644 --- a/packages/daemon/src/server/__tests__/http-server-rpc-validation.test.ts +++ b/packages/daemon/src/server/__tests__/http-server-rpc-validation.test.ts @@ -357,6 +357,48 @@ test('local command RPC keeps host paths unrestricted', async (t) => { } }); +test.for([ + ['omitted', {}, true], + ['blank', { session: ' ' }, true], + ['named', { session: 'agent-a' }, undefined], +] as const)( + 'install_from_source records a session that is %s', + async ([_label, session, sessionOmitted], t) => { + if (await skipWhenLoopbackUnavailable(t)) return; + + const received: DaemonRequest[] = []; + const server = await createDaemonHttpServer({ + handleRequest: async (req) => { + received.push(req); + return { ok: true, data: {} }; + }, + env: process.env, + cwd: process.cwd(), + }); + + try { + const port = await listenOnLoopback(server); + await fetch(`http://127.0.0.1:${port}/rpc`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 'req-1', + method: 'agent_device.install_from_source', + params: { + ...session, + platform: 'android', + source: { kind: 'url', url: 'https://example.com/app.apk' }, + }, + }), + }); + assert.equal(received[0]?.internal?.sessionOmitted, sessionOmitted); + } finally { + await closeLoopbackServer(server); + } + }, +); + test('lease.allocate forwards retainOnClose to the handler as lease meta', async (t) => { if (await skipWhenLoopbackUnavailable(t)) return; const received: DaemonRequest[] = []; diff --git a/packages/daemon/src/server/__tests__/request-execution-scope-unnamed-session.test.ts b/packages/daemon/src/server/__tests__/request-execution-scope-unnamed-session.test.ts new file mode 100644 index 0000000000..38752d87cc --- /dev/null +++ b/packages/daemon/src/server/__tests__/request-execution-scope-unnamed-session.test.ts @@ -0,0 +1,110 @@ +import { bindTestCommandHost, createTestHostEnvironment } from '@agent-device/test-kit'; +import { expect, test, vi } from 'vitest'; +import { + daemonClaimRecoveryGatewayFixture, + daemonPlatformServicesFixture, +} from '../../__tests__/support/platform-services-fixture.ts'; +import { makeIosSession } from '../../__tests__/support/session-factories.ts'; +import { makeSessionStore } from '../../__tests__/support/store-factory.ts'; +import { + platformRequestScopeFixture, + unavailableDeviceRuntimeGateway, +} from '../../__tests__/support/unavailable-device-runtime-gateway.ts'; +import type { DaemonRequest } from '../../core/daemon-request.ts'; +import { LeaseRegistry } from '../../resources/lease-registry.ts'; +import { createRequestExecutionScope } from '../request-execution-scope.ts'; + +bindTestCommandHost(); + +function createTestRequestScope( + params: Pick< + Parameters[0], + 'req' | 'sessionStore' | 'leaseRegistry' + >, +) { + return createRequestExecutionScope({ + host: createTestHostEnvironment(), + ...params, + platformServices: daemonPlatformServicesFixture(), + claimRecoveryGateway: daemonClaimRecoveryGatewayFixture(), + deviceRuntimeGateway: unavailableDeviceRuntimeGateway, + platformRequestScope: platformRequestScopeFixture(), + }); +} + +function makeRequest(overrides: Partial = {}): DaemonRequest { + return { + token: 'test-token', + session: 'default', + command: 'snapshot', + positionals: [], + ...overrides, + }; +} + +function publishProviderLeasedDefaultSession( + sessionStore: ReturnType, + leaseRegistry: LeaseRegistry, +): void { + const lease = leaseRegistry.allocateLease({ + tenantId: 'tenant-a', + runId: 'run-1', + leaseProvider: 'proxy', + clientId: 'client-a', + deviceKey: 'ios:sim-1', + }); + sessionStore.publish( + 'default', + makeIosSession('default', { + lease: { + leaseId: lease.leaseId, + tenantId: lease.tenantId, + runId: lease.runId, + leaseBackend: lease.backend, + leaseProvider: 'proxy', + clientId: 'client-a', + deviceKey: 'ios:sim-1', + expiresAt: lease.expiresAt, + }, + }), + ); +} + +test('an unnamed command cannot reach the provider lease another caller left on the default session', async () => { + const sessionStore = makeSessionStore('agent-device-request-scope-'); + const leaseRegistry = new LeaseRegistry(); + publishProviderLeasedDefaultSession(sessionStore, leaseRegistry); + const dispatch = vi.fn(async () => 'ran'); + + const run = async () => { + const scope = await createTestRequestScope({ + req: makeRequest({ command: 'snapshot', internal: { sessionOmitted: true } }), + sessionStore, + leaseRegistry, + }); + return await scope.runLocked(dispatch); + }; + + await expect(run()).rejects.toMatchObject({ + code: 'INVALID_ARGS', + details: { reason: 'REMOTE_SESSION_REQUIRED', leaseProvider: 'proxy' }, + }); + expect(dispatch).not.toHaveBeenCalled(); +}); + +test.each(['session_list', 'devices'])( + 'an unnamed %s still answers while the default session holds a provider lease', + async (command) => { + const sessionStore = makeSessionStore('agent-device-request-scope-'); + const leaseRegistry = new LeaseRegistry(); + publishProviderLeasedDefaultSession(sessionStore, leaseRegistry); + + const scope = await createTestRequestScope({ + req: makeRequest({ command, internal: { sessionOmitted: true } }), + sessionStore, + leaseRegistry, + }); + + await expect(scope.runLocked(async () => 'ran')).resolves.toBe('ran'); + }, +); diff --git a/packages/daemon/src/server/http/daemon-runtime-leased-session.test.ts b/packages/daemon/src/server/http/daemon-runtime-leased-session.test.ts index cf23dd70f3..ffeeadb268 100644 --- a/packages/daemon/src/server/http/daemon-runtime-leased-session.test.ts +++ b/packages/daemon/src/server/http/daemon-runtime-leased-session.test.ts @@ -87,8 +87,9 @@ test.each([ ])('a %s session is refused before a provider lease is allocated', async (_label, session) => { expectSessionRequired(await allocate({ ...PROXY_SCOPE, ...session })); - const named = await allocate({ ...PROXY_SCOPE, session: 'agent-a' }); - expect(named.result?.ok).toBe(true); + // Another client finds the device free, so the refused request left no lease behind. + const other = await allocate({ ...PROXY_SCOPE, clientId: 'client-b', session: 'agent-b' }); + expect(other.result?.ok, JSON.stringify(other)).toBe(true); }); test('an unnamed heartbeat is refused and the named lease stays usable', async () => { @@ -121,7 +122,7 @@ test.each([ ['shared', {}], ['tenant-isolated', { sessionIsolation: 'tenant' }], ])( - 'an unnamed leased command is refused before dispatch in a %s daemon', + 'an unnamed leased command is refused for its session in a %s daemon', async (_label, isolation) => { const leaseId = await allocateNamedLease('agent-a'); const { backend: leaseBackend, ...scope } = PROXY_SCOPE; @@ -147,7 +148,7 @@ test('a named leased command is not refused for its session', async () => { meta: { ...scope, leaseBackend, leaseId }, }); - expect(named.error?.data?.code, JSON.stringify(named)).toBeDefined(); + expect(named.error?.data?.code, JSON.stringify(named)).toBe('DEVICE_NOT_FOUND'); expect(named.error?.data?.details?.reason).not.toBe('REMOTE_SESSION_REQUIRED'); }); diff --git a/packages/daemon/src/server/http/http-rpc-methods.ts b/packages/daemon/src/server/http/http-rpc-methods.ts index d051eeb519..100400cbdd 100644 --- a/packages/daemon/src/server/http/http-rpc-methods.ts +++ b/packages/daemon/src/server/http/http-rpc-methods.ts @@ -235,7 +235,7 @@ function toInstallFromSourceDaemonRequest( } return { token: resolveToken(params, headers), - session: readStringParam(params, 'session') ?? 'default', + ...requestSessionFields(readStringParam(params, 'session')), command: 'install_source', positionals: [], flags: { platform }, diff --git a/packages/daemon/src/server/http/transport.test.ts b/packages/daemon/src/server/http/transport.test.ts index f43f64a545..4979f208df 100644 --- a/packages/daemon/src/server/http/transport.test.ts +++ b/packages/daemon/src/server/http/transport.test.ts @@ -83,34 +83,39 @@ test('Provider-backed integration daemon socket transport frames requests and no } }); -// A socket client that vanishes mid-request must cancel that connection's -// in-flight request via its request-scoped signal — never through a global -// Apple-runner abort, and never touching another connection's request. -test.each([ +test.for([ ['omitted', undefined, true], ['blank', ' ', true], ['named', 'agent-a', undefined], -])('the socket records a session that is %s', async (_label, session, sessionOmitted) => { - const observedRequests: DaemonRequest[] = []; - const server = createSocketServer(async (req): Promise => { - observedRequests.push(req); - return { ok: true, data: {} }; - }); +] as const)( + 'the socket records a session that is %s', + async ([_label, session, sessionOmitted], t) => { + if (await skipWhenLoopbackUnavailable(t)) return; - try { - const client = await connectSocket(await listenNetServer(server)); - await writeSocketRequests( - client, - [JSON.stringify({ token: 'token', session, command: 'session_list', positionals: [] })], - 1, - ); - client.destroy(); - assert.equal(observedRequests[0]?.internal?.sessionOmitted, sessionOmitted); - } finally { - await closeLoopbackServer(server); - } -}); + const observedRequests: DaemonRequest[] = []; + const server = createSocketServer(async (req): Promise => { + observedRequests.push(req); + return { ok: true, data: {} }; + }); + try { + const client = await connectSocket(await listenNetServer(server)); + await writeSocketRequests( + client, + [JSON.stringify({ token: 'token', session, command: 'session_list', positionals: [] })], + 1, + ); + client.destroy(); + assert.equal(observedRequests[0]?.internal?.sessionOmitted, sessionOmitted); + } finally { + await closeLoopbackServer(server); + } + }, +); + +// A socket client that vanishes mid-request must cancel that connection's +// in-flight request via its request-scoped signal — never through a global +// Apple-runner abort, and never touching another connection's request. test('socket disconnect cancels only the disconnected connection in-flight request', async (t) => { if (await skipWhenLoopbackUnavailable(t)) return; diff --git a/packages/daemon/src/sessions/handlers/session-batch.ts b/packages/daemon/src/sessions/handlers/session-batch.ts index b2c8cf4176..7735edcc47 100644 --- a/packages/daemon/src/sessions/handlers/session-batch.ts +++ b/packages/daemon/src/sessions/handlers/session-batch.ts @@ -14,6 +14,7 @@ export async function runBatchCommands( internal: { ...step.internal, ...(req.internal?.publicNetworkOnly ? { publicNetworkOnly: true } : {}), + ...(req.internal?.sessionOmitted ? { sessionOmitted: true } : {}), executionPlan: { remainingSteps: context.remainingSteps }, }, }); diff --git a/packages/daemon/src/sessions/request-admission.ts b/packages/daemon/src/sessions/request-admission.ts index addba37cc7..e2223cafcc 100644 --- a/packages/daemon/src/sessions/request-admission.ts +++ b/packages/daemon/src/sessions/request-admission.ts @@ -174,7 +174,8 @@ function hasSessionlessLeaseAdmissionExemption( /** * An unnamed session resolves to whichever connection holds the default name, so a request that * would allocate, use or release a provider lease has to name the session it means. Naming a - * provider alone is not that: a sessionless catalog read holds no lease. A request that names only + * provider alone is not that: a sessionless catalog read holds no lease. A lease-exempt command does + * not act through the session, so only a lease it carries itself counts. A request that names only * a lease id passes here too, because the registry's owner scope refuses it for omitting the * provider. */ @@ -183,7 +184,9 @@ export function assertProviderLeaseRequestNamesSession( session: SessionState | undefined, ): void { if (!req.internal?.sessionOmitted) return; - const { leaseProvider, leaseId } = resolveRequestOrSessionLeaseScope(req, session); + const { leaseProvider, leaseId } = isLeaseAdmissionExempt(req.command) + ? resolveLeaseScope(req) + : resolveRequestOrSessionLeaseScope(req, session); if (!leaseProvider) return; if (leaseId || req.command === 'lease_allocate') throw remoteSessionRequiredError(leaseProvider); } diff --git a/test/wire-compat/ledger.json b/test/wire-compat/ledger.json index b05f6044c2..91e71fe112 100644 --- a/test/wire-compat/ledger.json +++ b/test/wire-compat/ledger.json @@ -117,8 +117,9 @@ "packages/daemon/src/server/http/http-authorization.ts#readHeaderValue": "sha256:0ec16993e5d9b460ba02deb4a43bbc4c248f9697eaed9aa64003eec7d5dce6bf", "packages/daemon/src/server/http/http-authorization.ts#resolveToken": "sha256:3abcb019e1325317618045b7138e41845990af10953dd937571997cd6ec35981", "packages/daemon/src/server/http/http-rpc-envelope.ts#sendJson": "sha256:a1de00da3f1db8c98e69a85dfe2e11b626a4d2ce472840e3b8f519aa8a207a8f", + "packages/daemon/src/server/http/http-rpc-methods.ts#requestSessionFields": "sha256:b38d785ac807dac386873be0fee82d35386fe7a59b6be98bc5b6640af4f96cef", "packages/daemon/src/server/http/http-rpc-methods.ts#toDaemonRequest": "sha256:458d1568bb9d3ca3ae6f6aac47e1721489526ddeb9b5ae345a7b3328d855ba47", - "packages/daemon/src/server/http/http-rpc-methods.ts#toInstallFromSourceDaemonRequest": "sha256:dff093758b433043816dfc1e2e9658078875e46b887fdc530fb9380158d629f5", + "packages/daemon/src/server/http/http-rpc-methods.ts#toInstallFromSourceDaemonRequest": "sha256:8261a59a56ebb74104cb9179147f38287c98c94a3deb618f331bf6fec4ff5850", "packages/daemon/src/server/http/http-rpc-methods.ts#toLeaseDaemonRequest": "sha256:649db7b268ca9b8b95d1d2b7b22b349f5938711533347ab82a509f346da3aee8", "packages/daemon/src/server/http/http-rpc-methods.ts#toReleaseMaterializedPathsDaemonRequest": "sha256:708736ca03d6a3fc449bb408382dbd509e490de1f84ecc8771fd77d4adda0154", "packages/daemon/src/server/http/http-rpc-envelope.ts#writeProgressEnvelope": "sha256:7be778b3902c72d7aa6282c16dc7106fdd64e99c0b5daf9f413ff5dd3fb044f1", @@ -208,7 +209,7 @@ { "declaration": "packages/remote/src/daemon-artifacts.ts#DownloadRemoteArtifactParams", "digest": "sha256:ba6d2f6ac51e05ccc5e0c940b355d62374e018b1ae6f6f744becf6e1760cbb63", - "rationale": "#2246 adds the optional isDirectory field. It is pure client-local state \u2014 never serialized, never sent to the daemon \u2014 that tells the CLIENT'S OWN download logic to extract a tar body instead of writing it verbatim; the GET /artifacts/:id request and response framing are unchanged. Every existing call site (screenshot, recording) omits it and keeps writing a single file exactly as before. The signature also names the client request as DaemonWireRequest and the response as the kernel DaemonResponse instead of the daemon package aliases; only compile-time type names moved." + "rationale": "#2246 adds the optional isDirectory field. It is pure client-local state — never serialized, never sent to the daemon — that tells the CLIENT'S OWN download logic to extract a tar body instead of writing it verbatim; the GET /artifacts/:id request and response framing are unchanged. Every existing call site (screenshot, recording) omits it and keeps writing a single file exactly as before. The signature also names the client request as DaemonWireRequest and the response as the kernel DaemonResponse instead of the daemon package aliases; only compile-time type names moved." }, { "declaration": "packages/remote/src/daemon-artifacts.ts#downloadRemoteArtifact", @@ -225,15 +226,25 @@ "digest": "sha256:8978ef89d3af866bd94fec52c586a0cbf3217ee9e60952fbc826e8208e5fa158", "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. The signature also names the client request as DaemonWireRequest and the response as the kernel DaemonResponse instead of the daemon package aliases; only compile-time type names moved. It now composes the envelope through buildDaemonRpcRequest in @agent-device/contracts/lease-scope, which takes the request id from its caller; the client supplies the id it supplied before (the request meta id, else a fresh one), so envelope construction preserves method, params and id. Separately, credential fingerprint lookup now trims the provider name on both client and daemon. A whitespace-spelled provider can therefore add a fingerprint that the released client omitted; this changes fingerprint presence, not the envelope or optional parameter contract." }, + { + "declaration": "packages/daemon/src/server/http/http-rpc-methods.ts#requestSessionFields", + "digest": "sha256:b38d785ac807dac386873be0fee82d35386fe7a59b6be98bc5b6640af4f96cef", + "rationale": "#4171 records a blank or omitted session in daemon-only request state; an omitted name still becomes default. Released protocol-2 clients send a literal session name, so this check does not cover them and their payload is read as before, except that a blank name is now refused like an omitted one. A caller that names no session on a provider lease gets a normal INVALID_ARGS error with reason REMOTE_SESSION_REQUIRED, and no peer misreads a payload." + }, { "declaration": "packages/daemon/src/server/http/http-rpc-methods.ts#toDaemonRequest", "digest": "sha256:458d1568bb9d3ca3ae6f6aac47e1721489526ddeb9b5ae345a7b3328d855ba47", - "rationale": "#4171 records a blank or omitted session in daemon-only request state while still defaulting its name. Released protocol-2 clients always send a session on this envelope, so their payload is read as before. Only a direct RPC caller that names no session on a provider lease changes: it gets a normal INVALID_ARGS error with reason REMOTE_SESSION_REQUIRED, and no peer misreads a payload." + "rationale": "#4171 records a blank or omitted session in daemon-only request state; an omitted name still becomes default. Released protocol-2 clients send a literal session name, so this check does not cover them and their payload is read as before, except that a blank name is now refused like an omitted one. A caller that names no session on a provider lease gets a normal INVALID_ARGS error with reason REMOTE_SESSION_REQUIRED, and no peer misreads a payload." + }, + { + "declaration": "packages/daemon/src/server/http/http-rpc-methods.ts#toInstallFromSourceDaemonRequest", + "digest": "sha256:8261a59a56ebb74104cb9179147f38287c98c94a3deb618f331bf6fec4ff5850", + "rationale": "#4171 records a blank or omitted session in daemon-only request state; an omitted name still becomes default. Released protocol-2 clients send a literal session name, so this check does not cover them and their payload is read as before, except that a blank name is now refused like an omitted one. A caller that names no session on a provider lease gets a normal INVALID_ARGS error with reason REMOTE_SESSION_REQUIRED, and no peer misreads a payload." }, { "declaration": "packages/daemon/src/server/http/http-rpc-methods.ts#toLeaseDaemonRequest", "digest": "sha256:649db7b268ca9b8b95d1d2b7b22b349f5938711533347ab82a509f346da3aee8", - "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. #4171 records a blank or omitted session in daemon-only request state while still defaulting its name. Released protocol-2 clients always send a session on this envelope, so their payload is read as before. Only a direct RPC caller that names no session on a provider lease changes: it gets a normal INVALID_ARGS error with reason REMOTE_SESSION_REQUIRED, and no peer misreads a payload." + "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. #4171 records a blank or omitted session in daemon-only request state; an omitted name still becomes default. Released protocol-2 clients send a literal session name, so this check does not cover them and their payload is read as before, except that a blank name is now refused like an omitted one. A caller that names no session on a provider lease gets a normal INVALID_ARGS error with reason REMOTE_SESSION_REQUIRED, and no peer misreads a payload." }, { "declaration": "packages/kernel/src/errors.ts#DaemonError", @@ -293,7 +304,7 @@ { "declaration": "packages/daemon/src/server/http/http-authorization.ts#authorizeAuxiliaryHttpRequest", "digest": "sha256:1c323f97c3cefec8f95633b6abd127342bf75494bfb109d1cc6eadf3ceacaeae", - "rationale": "#2198 context: adds one optional field to the value this DAEMON-INTERNAL gate hands its own route handlers \u2014 sessionNamespace, the caller's tenant plus whether the daemon partitioned that caller's session names. Nothing about it is serialized: no request header is read that was not read before, no response field is added, and a refusal keeps the same 401 {ok:false,error,code} REST body sendRestJsonError already sent. What it enables is a refusal DROPPED, never one added \u2014 an unattested (client-declared) tenant no longer has the : prefix rule applied to it, because scopeRequestSession never applied that prefix to its sessions either. A released peer that now gets 200 where it got 401 is getting the ndjson record it asked for and already parses." + "rationale": "#2198 context: adds one optional field to the value this DAEMON-INTERNAL gate hands its own route handlers — sessionNamespace, the caller's tenant plus whether the daemon partitioned that caller's session names. Nothing about it is serialized: no request header is read that was not read before, no response field is added, and a refusal keeps the same 401 {ok:false,error,code} REST body sendRestJsonError already sent. What it enables is a refusal DROPPED, never one added — an unattested (client-declared) tenant no longer has the : prefix rule applied to it, because scopeRequestSession never applied that prefix to its sessions either. A released peer that now gets 200 where it got 401 is getting the ndjson record it asked for and already parses." }, { "declaration": "packages/daemon/src/server/request-diagnostics-http.ts#RequestDiagnosticsHttpAuthorizer", diff --git a/test/wire-compat/surface.ts b/test/wire-compat/surface.ts index 1a5baf4c99..a3676629c5 100644 --- a/test/wire-compat/surface.ts +++ b/test/wire-compat/surface.ts @@ -238,6 +238,7 @@ export const WIRE_SURFACE: readonly WireSurfaceGroup[] = [ 'isCommandRpcMethod', 'methodToDaemonRequest', 'parseCommandRpcParams', + 'requestSessionFields', 'toDaemonRequest', 'toLeaseDaemonRequest', 'toInstallFromSourceDaemonRequest', From 3f24cf830779c7bca5890ca9029d4f584ff21d19 Mon Sep 17 00:00:00 2001 From: Adam Rowe Date: Mon, 2 Nov 2026 15:34:46 -0500 Subject: [PATCH 3/9] fix(daemon): keep session-held leases out of unnamed artifacts and queued requests Only inventory and lease-lifecycle commands now ignore the lease on the session they resolve. artifacts and events act through that session, so an unnamed request for them is refused again. Admission also repeats the check under the session lock, because an unnamed request can wait behind an open that leaves a provider lease on the session. --- ...st-execution-scope-unnamed-session.test.ts | 46 ++++++++++++++----- .../src/server/request-execution-scope.ts | 2 + .../daemon/src/sessions/request-admission.ts | 15 ++++-- 3 files changed, 48 insertions(+), 15 deletions(-) diff --git a/packages/daemon/src/server/__tests__/request-execution-scope-unnamed-session.test.ts b/packages/daemon/src/server/__tests__/request-execution-scope-unnamed-session.test.ts index 38752d87cc..e793f983d1 100644 --- a/packages/daemon/src/server/__tests__/request-execution-scope-unnamed-session.test.ts +++ b/packages/daemon/src/server/__tests__/request-execution-scope-unnamed-session.test.ts @@ -70,29 +70,51 @@ function publishProviderLeasedDefaultSession( ); } -test('an unnamed command cannot reach the provider lease another caller left on the default session', async () => { +test.each(['snapshot', 'artifacts', 'events', 'logs'])( + 'an unnamed %s cannot reach the provider lease another caller left on the default session', + async (command) => { + const sessionStore = makeSessionStore('agent-device-request-scope-'); + const leaseRegistry = new LeaseRegistry(); + publishProviderLeasedDefaultSession(sessionStore, leaseRegistry); + const dispatch = vi.fn(async () => 'ran'); + + const run = async () => { + const scope = await createTestRequestScope({ + req: makeRequest({ command, internal: { sessionOmitted: true } }), + sessionStore, + leaseRegistry, + }); + return await scope.runLocked(dispatch); + }; + + await expect(run()).rejects.toMatchObject({ + code: 'INVALID_ARGS', + details: { reason: 'REMOTE_SESSION_REQUIRED', leaseProvider: 'proxy' }, + }); + expect(dispatch).not.toHaveBeenCalled(); + }, +); + +test('an unnamed command queued behind an open is refused once that open leaves a provider lease', async () => { const sessionStore = makeSessionStore('agent-device-request-scope-'); const leaseRegistry = new LeaseRegistry(); - publishProviderLeasedDefaultSession(sessionStore, leaseRegistry); const dispatch = vi.fn(async () => 'ran'); + const scope = await createTestRequestScope({ + req: makeRequest({ internal: { sessionOmitted: true } }), + sessionStore, + leaseRegistry, + }); - const run = async () => { - const scope = await createTestRequestScope({ - req: makeRequest({ command: 'snapshot', internal: { sessionOmitted: true } }), - sessionStore, - leaseRegistry, - }); - return await scope.runLocked(dispatch); - }; + publishProviderLeasedDefaultSession(sessionStore, leaseRegistry); - await expect(run()).rejects.toMatchObject({ + await expect(scope.runLocked(dispatch)).rejects.toMatchObject({ code: 'INVALID_ARGS', details: { reason: 'REMOTE_SESSION_REQUIRED', leaseProvider: 'proxy' }, }); expect(dispatch).not.toHaveBeenCalled(); }); -test.each(['session_list', 'devices'])( +test.each(['session_list', 'devices', 'capabilities', 'doctor'])( 'an unnamed %s still answers while the default session holds a provider lease', async (command) => { const sessionStore = makeSessionStore('agent-device-request-scope-'); diff --git a/packages/daemon/src/server/request-execution-scope.ts b/packages/daemon/src/server/request-execution-scope.ts index a560de058f..4820b1062c 100644 --- a/packages/daemon/src/server/request-execution-scope.ts +++ b/packages/daemon/src/server/request-execution-scope.ts @@ -376,6 +376,8 @@ export async function createRequestExecutionScope(params: { try { const existingRef = sessionStore.lookup(sessionName); const session = existingRef?.session; + // A session can gain its lease while this request waits for the lock. + assertProviderLeaseRequestNamesSession(scopedReq, session); const boundedResources = boundedStep ? await import('../core/composite-resource-admission.ts') : undefined; diff --git a/packages/daemon/src/sessions/request-admission.ts b/packages/daemon/src/sessions/request-admission.ts index e2223cafcc..314d6e19b8 100644 --- a/packages/daemon/src/sessions/request-admission.ts +++ b/packages/daemon/src/sessions/request-admission.ts @@ -3,7 +3,10 @@ import { AppError } from '@agent-device/kernel/errors'; import { isTenantOwnedSessionName, normalizeTenantId } from '@agent-device/contracts/daemon-http'; import { resolveSessionIsolationMode } from '../core/config.ts'; import { tenantScopedSessionName } from '../core/session-tenant-scope.ts'; +import { PUBLIC_COMMANDS } from '@agent-device/command-registry/catalog'; import { + getDaemonCommandRoute, + getSessionCommandKind, isAppLeaseAllowed, isLeaseAdmissionExempt, isHumanControlMutation, @@ -174,8 +177,7 @@ function hasSessionlessLeaseAdmissionExemption( /** * An unnamed session resolves to whichever connection holds the default name, so a request that * would allocate, use or release a provider lease has to name the session it means. Naming a - * provider alone is not that: a sessionless catalog read holds no lease. A lease-exempt command does - * not act through the session, so only a lease it carries itself counts. A request that names only + * provider alone is not that: a sessionless catalog read holds no lease. A request that names only * a lease id passes here too, because the registry's owner scope refuses it for omitting the * provider. */ @@ -184,13 +186,20 @@ export function assertProviderLeaseRequestNamesSession( session: SessionState | undefined, ): void { if (!req.internal?.sessionOmitted) return; - const { leaseProvider, leaseId } = isLeaseAdmissionExempt(req.command) + const { leaseProvider, leaseId } = ignoresSessionLease(req.command) ? resolveLeaseScope(req) : resolveRequestOrSessionLeaseScope(req, session); if (!leaseProvider) return; if (leaseId || req.command === 'lease_allocate') throw remoteSessionRequiredError(leaseProvider); } +/** Inventory and lease-lifecycle commands read only a lease they carry themselves. */ +function ignoresSessionLease(command: string): boolean { + if (!isLeaseAdmissionExempt(command)) return false; + if (getSessionCommandKind(command) === 'inventory') return true; + return getDaemonCommandRoute(command) === 'lease' && command !== PUBLIC_COMMANDS.artifacts; +} + export function assertRequestLeaseAdmissionPreflight(req: DaemonRequest): void { if (isLeaseAdmissionExempt(req.command)) return; assertProxyOpenLeaseMetadata(req, resolveLeaseScope(req)); From 6a2fdf360b2090551517b1f412f0dbffc92e7555 Mon Sep 17 00:00:00 2001 From: Adam Rowe Date: Mon, 2 Nov 2026 15:40:40 -0500 Subject: [PATCH 4/9] fix(daemon): refuse unnamed doctor and capabilities on a leased session Both resolve the session's device, so an unnamed request could inspect a device another caller leased. An explicit list now names the commands that never read the session, and every other command stays refused. release_materialized_paths joins that list, because it uses no session. --- ...st-execution-scope-unnamed-session.test.ts | 4 +-- .../daemon/src/sessions/request-admission.ts | 25 +++++++++++-------- website/docs/docs/remote-proxy.md | 2 +- 3 files changed, 17 insertions(+), 14 deletions(-) diff --git a/packages/daemon/src/server/__tests__/request-execution-scope-unnamed-session.test.ts b/packages/daemon/src/server/__tests__/request-execution-scope-unnamed-session.test.ts index e793f983d1..cd6bee7e5a 100644 --- a/packages/daemon/src/server/__tests__/request-execution-scope-unnamed-session.test.ts +++ b/packages/daemon/src/server/__tests__/request-execution-scope-unnamed-session.test.ts @@ -70,7 +70,7 @@ function publishProviderLeasedDefaultSession( ); } -test.each(['snapshot', 'artifacts', 'events', 'logs'])( +test.each(['snapshot', 'artifacts', 'events', 'logs', 'doctor', 'capabilities'])( 'an unnamed %s cannot reach the provider lease another caller left on the default session', async (command) => { const sessionStore = makeSessionStore('agent-device-request-scope-'); @@ -114,7 +114,7 @@ test('an unnamed command queued behind an open is refused once that open leaves expect(dispatch).not.toHaveBeenCalled(); }); -test.each(['session_list', 'devices', 'capabilities', 'doctor'])( +test.each(['session_list', 'devices', 'release_materialized_paths'])( 'an unnamed %s still answers while the default session holds a provider lease', async (command) => { const sessionStore = makeSessionStore('agent-device-request-scope-'); diff --git a/packages/daemon/src/sessions/request-admission.ts b/packages/daemon/src/sessions/request-admission.ts index 314d6e19b8..7510bbf1cc 100644 --- a/packages/daemon/src/sessions/request-admission.ts +++ b/packages/daemon/src/sessions/request-admission.ts @@ -3,10 +3,7 @@ import { AppError } from '@agent-device/kernel/errors'; import { isTenantOwnedSessionName, normalizeTenantId } from '@agent-device/contracts/daemon-http'; import { resolveSessionIsolationMode } from '../core/config.ts'; import { tenantScopedSessionName } from '../core/session-tenant-scope.ts'; -import { PUBLIC_COMMANDS } from '@agent-device/command-registry/catalog'; import { - getDaemonCommandRoute, - getSessionCommandKind, isAppLeaseAllowed, isLeaseAdmissionExempt, isHumanControlMutation, @@ -174,6 +171,19 @@ function hasSessionlessLeaseAdmissionExemption( ); } +/** + * These use neither the lease nor the device of the session they resolve, so only a lease they carry + * counts. Any other command acts through that session and stays refused. + */ +const COMMANDS_THAT_NEVER_READ_THE_SESSION: ReadonlySet = new Set([ + 'session_list', + 'devices', + 'lease_allocate', + 'lease_heartbeat', + 'lease_release', + 'release_materialized_paths', +]); + /** * An unnamed session resolves to whichever connection holds the default name, so a request that * would allocate, use or release a provider lease has to name the session it means. Naming a @@ -186,20 +196,13 @@ export function assertProviderLeaseRequestNamesSession( session: SessionState | undefined, ): void { if (!req.internal?.sessionOmitted) return; - const { leaseProvider, leaseId } = ignoresSessionLease(req.command) + const { leaseProvider, leaseId } = COMMANDS_THAT_NEVER_READ_THE_SESSION.has(req.command) ? resolveLeaseScope(req) : resolveRequestOrSessionLeaseScope(req, session); if (!leaseProvider) return; if (leaseId || req.command === 'lease_allocate') throw remoteSessionRequiredError(leaseProvider); } -/** Inventory and lease-lifecycle commands read only a lease they carry themselves. */ -function ignoresSessionLease(command: string): boolean { - if (!isLeaseAdmissionExempt(command)) return false; - if (getSessionCommandKind(command) === 'inventory') return true; - return getDaemonCommandRoute(command) === 'lease' && command !== PUBLIC_COMMANDS.artifacts; -} - export function assertRequestLeaseAdmissionPreflight(req: DaemonRequest): void { if (isLeaseAdmissionExempt(req.command)) return; assertProxyOpenLeaseMetadata(req, resolveLeaseScope(req)); diff --git a/website/docs/docs/remote-proxy.md b/website/docs/docs/remote-proxy.md index ff67c4c65f..e56b910762 100644 --- a/website/docs/docs/remote-proxy.md +++ b/website/docs/docs/remote-proxy.md @@ -44,7 +44,7 @@ agent-device disconnect Every command on a proxy connection must name its session, with `AGENT_DEVICE_SESSION`, `--session `, or a `session` in your config. The same applies to every device-cloud connection. A command that names none fails with `INVALID_ARGS` and `details.reason: "REMOTE_SESSION_REQUIRED"`, and sends nothing to the proxy. It never falls back to the connection that another agent on the same machine saved last. This includes `connect proxy` and `open --remote-config ` with a proxy profile. The client and run that the proxy leases under come from the session name, so two agents with different names never share a lease. -The daemon enforces the same rule for direct RPC callers. An `agent_device.command` or `agent_device.lease.*` request with no `session` param that would allocate, use, or release a lease from a lease provider fails with the same reason before anything is allocated or dispatched. Requests with no lease provider keep their default session. +The daemon enforces the same rule for direct RPC callers. An `agent_device.command`, `agent_device.install_from_source`, or `agent_device.lease.*` request with no `session` param that would allocate, use, or release a lease from a lease provider fails with the same reason before anything is allocated or dispatched. Requests with no lease provider keep their default session. `--daemon-auth-token ` works instead of the environment variable, but it authenticates only the command you pass it to. Later commands need the token again through the environment variable, a `daemonAuthToken` entry in your remote config profile, or the flag on each command. From 8c6183747900149bb449cc37249543915bc37447 Mon Sep 17 00:00:00 2001 From: Adam Rowe Date: Mon, 2 Nov 2026 16:06:12 -0500 Subject: [PATCH 5/9] fix(daemon): treat a blank session as the default one at intake A blank session name was marked as unnamed but stayed its own session, so the marker described a request that still ran somewhere else. HTTP and socket intake now both resolve a missing or blank name to the default session. A mutation case holds the marker in place, and the transport tests assert that the handler ran on that session. --- packages/daemon/src/core/daemon-request.ts | 6 +++--- .../__tests__/http-server-rpc-validation.test.ts | 10 ++++++---- .../request-execution-scope-unnamed-session.test.ts | 2 +- packages/daemon/src/server/http/http-rpc-methods.ts | 5 +---- packages/daemon/src/server/http/transport.test.ts | 10 ++++++---- packages/daemon/src/server/http/transport.ts | 2 +- test/wire-compat/ledger.json | 12 ++++++------ test/wire-compat/wire-mutations.test.ts | 7 +++++++ website/docs/docs/remote-proxy.md | 2 +- 9 files changed, 32 insertions(+), 24 deletions(-) diff --git a/packages/daemon/src/core/daemon-request.ts b/packages/daemon/src/core/daemon-request.ts index 7d0bbd1981..bd2c113ec9 100644 --- a/packages/daemon/src/core/daemon-request.ts +++ b/packages/daemon/src/core/daemon-request.ts @@ -41,9 +41,9 @@ type DaemonRequestInternal = ReplayDispatchOptions & { retainDeviceExecutionLock?: (deviceId: string) => Promise; admittedLease?: DeviceLease; /** - * The caller named no session and the transport that received the request filled in the default - * one. Set only at intake, because the defaulted name cannot be told apart from a chosen one - * afterwards. + * The caller sent a missing or blank session, and the transport that received the request named + * the default one. Set only at intake, because the defaulted name cannot be told apart from a + * chosen one afterwards. */ sessionOmitted?: true; /** diff --git a/packages/daemon/src/server/__tests__/http-server-rpc-validation.test.ts b/packages/daemon/src/server/__tests__/http-server-rpc-validation.test.ts index 64f546c11c..1ce2e71693 100644 --- a/packages/daemon/src/server/__tests__/http-server-rpc-validation.test.ts +++ b/packages/daemon/src/server/__tests__/http-server-rpc-validation.test.ts @@ -358,12 +358,12 @@ test('local command RPC keeps host paths unrestricted', async (t) => { }); test.for([ - ['omitted', {}, true], - ['blank', { session: ' ' }, true], - ['named', { session: 'agent-a' }, undefined], + ['omitted', {}, 'default', true], + ['blank', { session: ' ' }, 'default', true], + ['named', { session: 'agent-a' }, 'agent-a', undefined], ] as const)( 'install_from_source records a session that is %s', - async ([_label, session, sessionOmitted], t) => { + async ([_label, session, resolvedSession, sessionOmitted], t) => { if (await skipWhenLoopbackUnavailable(t)) return; const received: DaemonRequest[] = []; @@ -392,6 +392,8 @@ test.for([ }, }), }); + assert.equal(received.length, 1); + assert.equal(received[0]?.session, resolvedSession); assert.equal(received[0]?.internal?.sessionOmitted, sessionOmitted); } finally { await closeLoopbackServer(server); diff --git a/packages/daemon/src/server/__tests__/request-execution-scope-unnamed-session.test.ts b/packages/daemon/src/server/__tests__/request-execution-scope-unnamed-session.test.ts index cd6bee7e5a..e5d925e16a 100644 --- a/packages/daemon/src/server/__tests__/request-execution-scope-unnamed-session.test.ts +++ b/packages/daemon/src/server/__tests__/request-execution-scope-unnamed-session.test.ts @@ -95,7 +95,7 @@ test.each(['snapshot', 'artifacts', 'events', 'logs', 'doctor', 'capabilities']) }, ); -test('an unnamed command queued behind an open is refused once that open leaves a provider lease', async () => { +test('an unnamed command is refused when the session gains a provider lease after the first check', async () => { const sessionStore = makeSessionStore('agent-device-request-scope-'); const leaseRegistry = new LeaseRegistry(); const dispatch = vi.fn(async () => 'ran'); diff --git a/packages/daemon/src/server/http/http-rpc-methods.ts b/packages/daemon/src/server/http/http-rpc-methods.ts index 100400cbdd..4e7b665c25 100644 --- a/packages/daemon/src/server/http/http-rpc-methods.ts +++ b/packages/daemon/src/server/http/http-rpc-methods.ts @@ -45,10 +45,7 @@ const SUPPORTED_RPC_METHODS = new Set([ function requestSessionFields( session: string | undefined, ): Pick { - return { - session: session ?? 'default', - ...(session?.trim() ? {} : { internal: { sessionOmitted: true } }), - }; + return session?.trim() ? { session } : { session: 'default', internal: { sessionOmitted: true } }; } function toDaemonRequest(params: CommandRpcParams, headers: IncomingHttpHeaders): DaemonRequest { diff --git a/packages/daemon/src/server/http/transport.test.ts b/packages/daemon/src/server/http/transport.test.ts index 4979f208df..a9f7d16493 100644 --- a/packages/daemon/src/server/http/transport.test.ts +++ b/packages/daemon/src/server/http/transport.test.ts @@ -84,12 +84,12 @@ test('Provider-backed integration daemon socket transport frames requests and no }); test.for([ - ['omitted', undefined, true], - ['blank', ' ', true], - ['named', 'agent-a', undefined], + ['omitted', undefined, 'default', true], + ['blank', ' ', 'default', true], + ['named', 'agent-a', 'agent-a', undefined], ] as const)( 'the socket records a session that is %s', - async ([_label, session, sessionOmitted], t) => { + async ([_label, session, resolvedSession, sessionOmitted], t) => { if (await skipWhenLoopbackUnavailable(t)) return; const observedRequests: DaemonRequest[] = []; @@ -106,6 +106,8 @@ test.for([ 1, ); client.destroy(); + assert.equal(observedRequests.length, 1); + assert.equal(observedRequests[0]?.session, resolvedSession); assert.equal(observedRequests[0]?.internal?.sessionOmitted, sessionOmitted); } finally { await closeLoopbackServer(server); diff --git a/packages/daemon/src/server/http/transport.ts b/packages/daemon/src/server/http/transport.ts index 142aafffad..881209806b 100644 --- a/packages/daemon/src/server/http/transport.ts +++ b/packages/daemon/src/server/http/transport.ts @@ -137,7 +137,7 @@ function parseSocketDaemonRequest(line: string): DaemonRequest { return ( typeof session === 'string' && session.trim() ? request - : { ...request, internal: { sessionOmitted: true } } + : { ...request, session: 'default', internal: { sessionOmitted: true } } ) as DaemonRequest; } diff --git a/test/wire-compat/ledger.json b/test/wire-compat/ledger.json index 91e71fe112..5305517263 100644 --- a/test/wire-compat/ledger.json +++ b/test/wire-compat/ledger.json @@ -117,7 +117,7 @@ "packages/daemon/src/server/http/http-authorization.ts#readHeaderValue": "sha256:0ec16993e5d9b460ba02deb4a43bbc4c248f9697eaed9aa64003eec7d5dce6bf", "packages/daemon/src/server/http/http-authorization.ts#resolveToken": "sha256:3abcb019e1325317618045b7138e41845990af10953dd937571997cd6ec35981", "packages/daemon/src/server/http/http-rpc-envelope.ts#sendJson": "sha256:a1de00da3f1db8c98e69a85dfe2e11b626a4d2ce472840e3b8f519aa8a207a8f", - "packages/daemon/src/server/http/http-rpc-methods.ts#requestSessionFields": "sha256:b38d785ac807dac386873be0fee82d35386fe7a59b6be98bc5b6640af4f96cef", + "packages/daemon/src/server/http/http-rpc-methods.ts#requestSessionFields": "sha256:a38a97be4fe63f6a0e1241722d3040219332ecb78233513ca2f178a2c8742953", "packages/daemon/src/server/http/http-rpc-methods.ts#toDaemonRequest": "sha256:458d1568bb9d3ca3ae6f6aac47e1721489526ddeb9b5ae345a7b3328d855ba47", "packages/daemon/src/server/http/http-rpc-methods.ts#toInstallFromSourceDaemonRequest": "sha256:8261a59a56ebb74104cb9179147f38287c98c94a3deb618f331bf6fec4ff5850", "packages/daemon/src/server/http/http-rpc-methods.ts#toLeaseDaemonRequest": "sha256:649db7b268ca9b8b95d1d2b7b22b349f5938711533347ab82a509f346da3aee8", @@ -228,23 +228,23 @@ }, { "declaration": "packages/daemon/src/server/http/http-rpc-methods.ts#requestSessionFields", - "digest": "sha256:b38d785ac807dac386873be0fee82d35386fe7a59b6be98bc5b6640af4f96cef", - "rationale": "#4171 records a blank or omitted session in daemon-only request state; an omitted name still becomes default. Released protocol-2 clients send a literal session name, so this check does not cover them and their payload is read as before, except that a blank name is now refused like an omitted one. A caller that names no session on a provider lease gets a normal INVALID_ARGS error with reason REMOTE_SESSION_REQUIRED, and no peer misreads a payload." + "digest": "sha256:a38a97be4fe63f6a0e1241722d3040219332ecb78233513ca2f178a2c8742953", + "rationale": "#4171 records a blank or omitted session in daemon-only request state; both now become default. Released protocol-2 clients send a literal session name, so this check does not cover them and their payload is read as before, except that a blank name is now refused like an omitted one. A caller that names no session on a provider lease gets a normal INVALID_ARGS error with reason REMOTE_SESSION_REQUIRED, and no peer misreads a payload." }, { "declaration": "packages/daemon/src/server/http/http-rpc-methods.ts#toDaemonRequest", "digest": "sha256:458d1568bb9d3ca3ae6f6aac47e1721489526ddeb9b5ae345a7b3328d855ba47", - "rationale": "#4171 records a blank or omitted session in daemon-only request state; an omitted name still becomes default. Released protocol-2 clients send a literal session name, so this check does not cover them and their payload is read as before, except that a blank name is now refused like an omitted one. A caller that names no session on a provider lease gets a normal INVALID_ARGS error with reason REMOTE_SESSION_REQUIRED, and no peer misreads a payload." + "rationale": "#4171 records a blank or omitted session in daemon-only request state; both now become default. Released protocol-2 clients send a literal session name, so this check does not cover them and their payload is read as before, except that a blank name is now refused like an omitted one. A caller that names no session on a provider lease gets a normal INVALID_ARGS error with reason REMOTE_SESSION_REQUIRED, and no peer misreads a payload." }, { "declaration": "packages/daemon/src/server/http/http-rpc-methods.ts#toInstallFromSourceDaemonRequest", "digest": "sha256:8261a59a56ebb74104cb9179147f38287c98c94a3deb618f331bf6fec4ff5850", - "rationale": "#4171 records a blank or omitted session in daemon-only request state; an omitted name still becomes default. Released protocol-2 clients send a literal session name, so this check does not cover them and their payload is read as before, except that a blank name is now refused like an omitted one. A caller that names no session on a provider lease gets a normal INVALID_ARGS error with reason REMOTE_SESSION_REQUIRED, and no peer misreads a payload." + "rationale": "#4171 records a blank or omitted session in daemon-only request state; both now become default. Released protocol-2 clients send a literal session name, so this check does not cover them and their payload is read as before, except that a blank name is now refused like an omitted one. A caller that names no session on a provider lease gets a normal INVALID_ARGS error with reason REMOTE_SESSION_REQUIRED, and no peer misreads a payload." }, { "declaration": "packages/daemon/src/server/http/http-rpc-methods.ts#toLeaseDaemonRequest", "digest": "sha256:649db7b268ca9b8b95d1d2b7b22b349f5938711533347ab82a509f346da3aee8", - "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. #4171 records a blank or omitted session in daemon-only request state; an omitted name still becomes default. Released protocol-2 clients send a literal session name, so this check does not cover them and their payload is read as before, except that a blank name is now refused like an omitted one. A caller that names no session on a provider lease gets a normal INVALID_ARGS error with reason REMOTE_SESSION_REQUIRED, and no peer misreads a payload." + "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. #4171 records a blank or omitted session in daemon-only request state; both now become default. Released protocol-2 clients send a literal session name, so this check does not cover them and their payload is read as before, except that a blank name is now refused like an omitted one. A caller that names no session on a provider lease gets a normal INVALID_ARGS error with reason REMOTE_SESSION_REQUIRED, and no peer misreads a payload." }, { "declaration": "packages/kernel/src/errors.ts#DaemonError", diff --git a/test/wire-compat/wire-mutations.test.ts b/test/wire-compat/wire-mutations.test.ts index b2e04447f1..bdd6d7a1eb 100644 --- a/test/wire-compat/wire-mutations.test.ts +++ b/test/wire-compat/wire-mutations.test.ts @@ -84,6 +84,13 @@ const MUTATIONS: readonly WireMutation[] = [ from: "'agent-device.lease.allocate': 'lease_allocate',", to: '', }, + { + breakClass: 'session intake: an unnamed request stops being marked as unnamed', + file: 'packages/daemon/src/server/http/http-rpc-methods.ts', + name: 'requestSessionFields', + from: 'internal: { sessionOmitted: true }', + to: 'internal: {}', + }, { breakClass: 'response serialization: the RPC envelope stops being newline-framed', file: 'packages/daemon-contracts/src/request-progress-protocol.ts', diff --git a/website/docs/docs/remote-proxy.md b/website/docs/docs/remote-proxy.md index e56b910762..14dbe1df11 100644 --- a/website/docs/docs/remote-proxy.md +++ b/website/docs/docs/remote-proxy.md @@ -44,7 +44,7 @@ agent-device disconnect Every command on a proxy connection must name its session, with `AGENT_DEVICE_SESSION`, `--session `, or a `session` in your config. The same applies to every device-cloud connection. A command that names none fails with `INVALID_ARGS` and `details.reason: "REMOTE_SESSION_REQUIRED"`, and sends nothing to the proxy. It never falls back to the connection that another agent on the same machine saved last. This includes `connect proxy` and `open --remote-config ` with a proxy profile. The client and run that the proxy leases under come from the session name, so two agents with different names never share a lease. -The daemon enforces the same rule for direct RPC callers. An `agent_device.command`, `agent_device.install_from_source`, or `agent_device.lease.*` request with no `session` param that would allocate, use, or release a lease from a lease provider fails with the same reason before anything is allocated or dispatched. Requests with no lease provider keep their default session. +The daemon enforces the same rule for direct RPC callers. An `agent_device.command`, `agent_device.install_from_source`, or `agent_device.lease.*` request with a missing or blank `session` param that would allocate, use, or release a lease from a lease provider fails with the same reason before anything is allocated or dispatched. Requests with no lease provider keep their default session. `--daemon-auth-token ` works instead of the environment variable, but it authenticates only the command you pass it to. Later commands need the token again through the environment variable, a `daemonAuthToken` entry in your remote config profile, or the flag on each command. From c5987ee11920d3f02f64d62d167c783ef66df329 Mon Sep 17 00:00:00 2001 From: Adam Rowe Date: Mon, 2 Nov 2026 17:54:05 -0500 Subject: [PATCH 6/9] fix(daemon): refuse the Node client on a provider lease when it names no session The Node client always sends the literal default session and reports the omission in meta.sessionExplicit, so intake never marked it and an unnamed leases.allocate still allocated a lease. One intake helper now decides this for the socket and every HTTP builder, and lease RPC params carry the fact. The commands that ignore their session's lease become a registry trait. Docs examples and in-repo clients on a provider lease now name a session. --- .../src/daemon-command-descriptor.ts | 6 +++ .../src/descriptors/session-operations.ts | 1 + packages/command-registry/src/registry.ts | 5 +++ packages/contracts/src/lease-scope.ts | 15 ++++--- .../__tests__/daemon-command-registry.test.ts | 18 ++++++++ .../src/core/daemon-command-registry.ts | 4 ++ packages/daemon/src/core/daemon-request.ts | 5 +-- .../src/core/request-session-intake.test.ts | 29 +++++++++++++ .../daemon/src/core/request-session-intake.ts | 24 +++++++++++ .../daemon-runtime-leased-session.test.ts | 32 +++++++++++++++ .../src/server/http/http-rpc-methods.ts | 18 ++++---- packages/daemon/src/server/http/transport.ts | 8 +--- .../daemon/src/sessions/request-admission.ts | 16 +------- src/__tests__/cli-leased-session.test.ts | 36 ++++++++++++++++ .../lease-provider-profile-fields.test.ts | 1 + src/provider-device-runtimes.test.ts | 6 ++- .../cloud-webdriver-runtime.test.ts | 1 + test/wire-compat/ledger.json | 41 +++++++++++-------- test/wire-compat/surface.ts | 5 ++- test/wire-compat/wire-mutations.test.ts | 4 +- website/docs/docs/aws-device-farm.md | 1 + website/docs/docs/browserstack.md | 1 + website/docs/docs/client-api.md | 3 +- website/docs/docs/limrun.md | 2 +- website/docs/docs/remote-proxy.md | 2 +- website/docs/docs/revyl.md | 1 + website/docs/docs/testmu.md | 1 + 27 files changed, 223 insertions(+), 63 deletions(-) create mode 100644 packages/daemon/src/core/request-session-intake.test.ts create mode 100644 packages/daemon/src/core/request-session-intake.ts diff --git a/packages/command-registry/src/daemon-command-descriptor.ts b/packages/command-registry/src/daemon-command-descriptor.ts index b212e6cab3..db686a6438 100644 --- a/packages/command-registry/src/daemon-command-descriptor.ts +++ b/packages/command-registry/src/daemon-command-descriptor.ts @@ -38,6 +38,12 @@ export type DaemonCommandDescriptor = { sessionKind?: SessionCommandKind; refFrameEffect?: DaemonRefFrameEffect; leaseAdmissionExempt?: boolean; + /** + * This command uses neither the lease nor the device of the session it resolves, so only a lease + * it carries itself can make an unnamed request need a session. A command without it acts through + * that session and stays refused. + */ + ignoresSessionLease?: boolean; /** * ADR 0007: this command may run under a `macos-app` lease, or on a daemon whose policy requires * one. A command without it is refused there, `leaseAdmissionExempt` or not, so a command diff --git a/packages/command-registry/src/descriptors/session-operations.ts b/packages/command-registry/src/descriptors/session-operations.ts index 96e4f89ab5..606e21a744 100644 --- a/packages/command-registry/src/descriptors/session-operations.ts +++ b/packages/command-registry/src/descriptors/session-operations.ts @@ -336,6 +336,7 @@ export const SESSION_OPERATION_COMMAND_DESCRIPTORS = [ daemon: { route: 'session', refFrameEffect: 'preserve', + ignoresSessionLease: true, ...REQUEST_EXECUTION_EXEMPT, }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, diff --git a/packages/command-registry/src/registry.ts b/packages/command-registry/src/registry.ts index 43db4df13e..71ddfeb4ff 100644 --- a/packages/command-registry/src/registry.ts +++ b/packages/command-registry/src/registry.ts @@ -173,6 +173,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ daemon: { route: 'lease', refFrameEffect: 'preserve', + ignoresSessionLease: true, ...ADMISSION_AND_LOCK_EXEMPT, }, timeoutPolicy: LEASE_ALLOCATE_TIMEOUT_POLICY, @@ -190,6 +191,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ daemon: { route: 'lease', refFrameEffect: 'preserve', + ignoresSessionLease: true, ...ADMISSION_AND_LOCK_EXEMPT, appLease: 'allowed', }, @@ -208,6 +210,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ daemon: { route: 'lease', refFrameEffect: 'preserve', + ignoresSessionLease: true, ...ADMISSION_AND_LOCK_EXEMPT, appLease: 'allowed', }, @@ -250,6 +253,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ daemon: { route: 'session', refFrameEffect: 'preserve', + ignoresSessionLease: true, sessionKind: 'inventory', ...REQUEST_EXECUTION_EXEMPT, }, @@ -290,6 +294,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ daemon: { route: 'session', refFrameEffect: 'preserve', + ignoresSessionLease: true, sessionKind: 'inventory', lockPolicySelectorOverride: true, ...REQUEST_EXECUTION_EXEMPT, diff --git a/packages/contracts/src/lease-scope.ts b/packages/contracts/src/lease-scope.ts index 66bd95cdf7..e38718685e 100644 --- a/packages/contracts/src/lease-scope.ts +++ b/packages/contracts/src/lease-scope.ts @@ -611,9 +611,14 @@ function buildLeaseRpcParams( command: LeaseRpcCommand, options: { includeTokenParam: boolean }, ): Record { - return leaseScopeToLeaseRpcParams(leaseScopeFromRequest(req), command, { - includeTokenParam: options.includeTokenParam, - token: req.token, - session: req.session, - }); + return { + ...leaseScopeToLeaseRpcParams(leaseScopeFromRequest(req), command, { + includeTokenParam: options.includeTokenParam, + token: req.token, + session: req.session, + }), + ...(req.meta?.sessionExplicit === undefined + ? {} + : { sessionExplicit: req.meta.sessionExplicit }), + }; } diff --git a/packages/daemon/src/core/__tests__/daemon-command-registry.test.ts b/packages/daemon/src/core/__tests__/daemon-command-registry.test.ts index 355866dc23..9df89295eb 100644 --- a/packages/daemon/src/core/__tests__/daemon-command-registry.test.ts +++ b/packages/daemon/src/core/__tests__/daemon-command-registry.test.ts @@ -4,8 +4,10 @@ import { INTERNAL_COMMANDS, PUBLIC_COMMANDS } from '@agent-device/command-regist import { canOverrideLockPolicySelector, canRunReplayScopedAction, + DAEMON_COMMAND_DESCRIPTORS, getDaemonCommandRoute, getSessionCommandKind, + ignoresSessionLease, isHumanControlMutation, isLeaseAdmissionExempt, resolveSessionlessLeaseAdmissionExemption, @@ -19,6 +21,22 @@ import { } from '../daemon-command-registry.ts'; import type { DaemonRequest } from '../daemon-request.ts'; +test('only the commands that never touch their session ignore its lease', () => { + assert.deepEqual( + DAEMON_COMMAND_DESCRIPTORS.map(({ command }) => command) + .filter(ignoresSessionLease) + .sort(), + [ + PUBLIC_COMMANDS.devices, + INTERNAL_COMMANDS.leaseAllocate, + INTERNAL_COMMANDS.leaseHeartbeat, + INTERNAL_COMMANDS.leaseRelease, + INTERNAL_COMMANDS.releaseMaterializedPaths, + INTERNAL_COMMANDS.sessionList, + ].sort(), + ); +}); + test('daemon command registry owns specialized handler routes', () => { assert.equal(getDaemonCommandRoute(INTERNAL_COMMANDS.humanControl), 'humanControl'); for (const command of [ diff --git a/packages/daemon/src/core/daemon-command-registry.ts b/packages/daemon/src/core/daemon-command-registry.ts index 279acd28a5..bbfc477a2d 100644 --- a/packages/daemon/src/core/daemon-command-registry.ts +++ b/packages/daemon/src/core/daemon-command-registry.ts @@ -66,6 +66,10 @@ export function isLeaseAdmissionExempt(command: string): boolean { return getDaemonCommandDescriptor(command)?.leaseAdmissionExempt === true; } +export function ignoresSessionLease(command: string): boolean { + return getDaemonCommandDescriptor(command)?.ignoresSessionLease === true; +} + export function isAppLeaseAllowed(command: string): boolean { return getDaemonCommandDescriptor(command)?.appLease === 'allowed'; } diff --git a/packages/daemon/src/core/daemon-request.ts b/packages/daemon/src/core/daemon-request.ts index bd2c113ec9..1857565374 100644 --- a/packages/daemon/src/core/daemon-request.ts +++ b/packages/daemon/src/core/daemon-request.ts @@ -41,9 +41,8 @@ type DaemonRequestInternal = ReplayDispatchOptions & { retainDeviceExecutionLock?: (deviceId: string) => Promise; admittedLease?: DeviceLease; /** - * The caller sent a missing or blank session, and the transport that received the request named - * the default one. Set only at intake, because the defaulted name cannot be told apart from a - * chosen one afterwards. + * The caller named no session. Set only by `resolveRequestSessionAtIntake`, because the defaulted + * name cannot be told apart from a chosen one afterwards. */ sessionOmitted?: true; /** diff --git a/packages/daemon/src/core/request-session-intake.test.ts b/packages/daemon/src/core/request-session-intake.test.ts new file mode 100644 index 0000000000..9293ed0a34 --- /dev/null +++ b/packages/daemon/src/core/request-session-intake.test.ts @@ -0,0 +1,29 @@ +import { expect, test } from 'vitest'; +import { resolveRequestSessionAtIntake } from './request-session-intake.ts'; + +const OMITTED = { internal: { sessionOmitted: true } }; + +test.each([ + ['a missing session', {}, { session: 'default', ...OMITTED }], + ['a blank session', { session: ' ' }, { session: 'default', ...OMITTED }], + ['a session that is not a string', { session: 7 }, { session: 'default', ...OMITTED }], + [ + 'the literal default from a client that named none', + { session: 'default', meta: { sessionExplicit: false } }, + { session: 'default', ...OMITTED }, + ], + [ + 'a client that names the default session in its meta', + { session: 'default', meta: { sessionExplicit: true } }, + { session: 'default' }, + ], + [ + 'a client that names its session in its flags', + { session: 'default', flags: { session: 'default' }, meta: { sessionExplicit: false } }, + { session: 'default' }, + ], + ['a caller that predates the fact', { session: 'default' }, { session: 'default' }], + ['a named session', { session: 'agent-a' }, { session: 'agent-a' }], +])('intake reads %s', (_label, request, expected) => { + expect(resolveRequestSessionAtIntake(request)).toEqual(expected); +}); diff --git a/packages/daemon/src/core/request-session-intake.ts b/packages/daemon/src/core/request-session-intake.ts new file mode 100644 index 0000000000..4d28b85bef --- /dev/null +++ b/packages/daemon/src/core/request-session-intake.ts @@ -0,0 +1,24 @@ +import { namesSessionExplicitly } from '@agent-device/kernel/contracts'; +import type { DaemonRequest } from './daemon-request.ts'; + +/** + * What a transport takes from a request's session: the name it runs under, and whether the caller + * named one at all. A missing or blank session runs under `default`. The Node client always sends + * that literal and reports the omission in `meta.sessionExplicit`, so the literal stays and + * cwd-scoped routing keeps reading it. A caller on neither carrier predates the fact and counts as + * named. + */ +export function resolveRequestSessionAtIntake(request: { + session?: unknown; + flags?: { session?: unknown } | Record; + meta?: { sessionExplicit?: boolean | undefined }; +}): Pick { + const { session } = request; + const named = typeof session === 'string' && session.trim().length > 0; + const omitted = + !named || (request.meta?.sessionExplicit === false && !namesSessionExplicitly(request)); + return { + session: named ? session : 'default', + ...(omitted ? { internal: { sessionOmitted: true } } : {}), + }; +} diff --git a/packages/daemon/src/server/http/daemon-runtime-leased-session.test.ts b/packages/daemon/src/server/http/daemon-runtime-leased-session.test.ts index ffeeadb268..47b0ca807e 100644 --- a/packages/daemon/src/server/http/daemon-runtime-leased-session.test.ts +++ b/packages/daemon/src/server/http/daemon-runtime-leased-session.test.ts @@ -92,6 +92,38 @@ test.each([ expect(other.result?.ok, JSON.stringify(other)).toBe(true); }); +test('a client that names no session is refused before a provider lease is allocated', async () => { + const { backend: leaseBackend, ...scope } = PROXY_SCOPE; + + const refused = await rpc('agent_device.command', { + session: 'default', + command: 'lease_allocate', + positionals: [], + meta: { ...scope, leaseBackend, sessionExplicit: false }, + }); + + expectSessionRequired(refused); + const other = await allocate({ ...PROXY_SCOPE, clientId: 'client-b', session: 'agent-b' }); + expect(other.result?.ok, JSON.stringify(other)).toBe(true); +}); + +test.each([ + ['in its meta', { sessionExplicit: true }, {}], + ['in its flags', { sessionExplicit: false }, { session: 'default' }], +])('a client that names the default session %s is not refused', async (_label, meta, flags) => { + const { backend: leaseBackend, ...scope } = PROXY_SCOPE; + + const allocated = await rpc('agent_device.command', { + session: 'default', + command: 'lease_allocate', + positionals: [], + flags, + meta: { ...scope, leaseBackend, ...meta }, + }); + + expect(allocated.result?.ok, JSON.stringify(allocated)).toBe(true); +}); + test('an unnamed heartbeat is refused and the named lease stays usable', async () => { const leaseId = await allocateNamedLease('agent-a'); diff --git a/packages/daemon/src/server/http/http-rpc-methods.ts b/packages/daemon/src/server/http/http-rpc-methods.ts index 4e7b665c25..398833296b 100644 --- a/packages/daemon/src/server/http/http-rpc-methods.ts +++ b/packages/daemon/src/server/http/http-rpc-methods.ts @@ -11,6 +11,7 @@ import type { import { commandRpcParamsSchema } from '@agent-device/kernel/contracts'; import { readLeaseAllocateProviderFlags } from '@agent-device/contracts/lease-scope'; import type { DaemonRequest } from '../../core/daemon-request.ts'; +import { resolveRequestSessionAtIntake } from '../../core/request-session-intake.ts'; import { resolveToken } from './http-authorization.ts'; type HttpInstallSource = Exclude; @@ -42,16 +43,10 @@ const SUPPORTED_RPC_METHODS = new Set([ ...Object.keys(LEASE_RPC_METHOD_TO_COMMAND), ]); -function requestSessionFields( - session: string | undefined, -): Pick { - return session?.trim() ? { session } : { session: 'default', internal: { sessionOmitted: true } }; -} - function toDaemonRequest(params: CommandRpcParams, headers: IncomingHttpHeaders): DaemonRequest { return { token: resolveToken(params as Record, headers), - ...requestSessionFields(params.session), + ...resolveRequestSessionAtIntake(params), command: params.command ?? '', positionals: params.positionals ?? [], input: params.input, @@ -154,7 +149,10 @@ function toLeaseDaemonRequest( ): DaemonRequest { return { token: resolveToken(params, headers), - ...requestSessionFields(readStringParam(params, 'session')), + ...resolveRequestSessionAtIntake({ + session: readStringParam(params, 'session'), + meta: { sessionExplicit: readBooleanParam(params, 'sessionExplicit') }, + }), command, positionals: [], flags: command === 'lease_allocate' ? readLeaseAllocateProviderFlags(params) : undefined, @@ -232,7 +230,7 @@ function toInstallFromSourceDaemonRequest( } return { token: resolveToken(params, headers), - ...requestSessionFields(readStringParam(params, 'session')), + ...resolveRequestSessionAtIntake({ session: readStringParam(params, 'session') }), command: 'install_source', positionals: [], flags: { platform }, @@ -255,7 +253,7 @@ function toReleaseMaterializedPathsDaemonRequest( } return { token: resolveToken(params, headers), - session: readStringParam(params, 'session') ?? 'default', + ...resolveRequestSessionAtIntake({ session: readStringParam(params, 'session') }), command: 'release_materialized_paths', positionals: [], meta: { diff --git a/packages/daemon/src/server/http/transport.ts b/packages/daemon/src/server/http/transport.ts index 881209806b..2dcd1a45aa 100644 --- a/packages/daemon/src/server/http/transport.ts +++ b/packages/daemon/src/server/http/transport.ts @@ -4,6 +4,7 @@ import { AsyncResource } from 'node:async_hooks'; import net from 'node:net'; import type { Server as HttpServer } from 'node:http'; import type { DaemonInvokeFn, DaemonRequest } from '../../core/daemon-request.ts'; +import { resolveRequestSessionAtIntake } from '../../core/request-session-intake.ts'; import { resolveDaemonRequestResultDeadlineMs } from '../../core/daemon-command-registry.ts'; import { clearRequestAbortRegistration, @@ -133,12 +134,7 @@ function parseSocketDaemonRequest(line: string): DaemonRequest { // forge those semantics even though the legacy socket request otherwise // preserves its existing raw wire shape. const { internal: _internal, ...request } = parsed as Record; - const session = request.session; - return ( - typeof session === 'string' && session.trim() - ? request - : { ...request, session: 'default', internal: { sessionOmitted: true } } - ) as DaemonRequest; + return { ...request, ...resolveRequestSessionAtIntake(request) } as DaemonRequest; } export function listenNetServer(server: net.Server): Promise { diff --git a/packages/daemon/src/sessions/request-admission.ts b/packages/daemon/src/sessions/request-admission.ts index 7510bbf1cc..4a5f4c99c3 100644 --- a/packages/daemon/src/sessions/request-admission.ts +++ b/packages/daemon/src/sessions/request-admission.ts @@ -5,6 +5,7 @@ import { resolveSessionIsolationMode } from '../core/config.ts'; import { tenantScopedSessionName } from '../core/session-tenant-scope.ts'; import { isAppLeaseAllowed, + ignoresSessionLease, isLeaseAdmissionExempt, isHumanControlMutation, resolveSessionlessLeaseAdmissionExemption, @@ -171,19 +172,6 @@ function hasSessionlessLeaseAdmissionExemption( ); } -/** - * These use neither the lease nor the device of the session they resolve, so only a lease they carry - * counts. Any other command acts through that session and stays refused. - */ -const COMMANDS_THAT_NEVER_READ_THE_SESSION: ReadonlySet = new Set([ - 'session_list', - 'devices', - 'lease_allocate', - 'lease_heartbeat', - 'lease_release', - 'release_materialized_paths', -]); - /** * An unnamed session resolves to whichever connection holds the default name, so a request that * would allocate, use or release a provider lease has to name the session it means. Naming a @@ -196,7 +184,7 @@ export function assertProviderLeaseRequestNamesSession( session: SessionState | undefined, ): void { if (!req.internal?.sessionOmitted) return; - const { leaseProvider, leaseId } = COMMANDS_THAT_NEVER_READ_THE_SESSION.has(req.command) + const { leaseProvider, leaseId } = ignoresSessionLease(req.command) ? resolveLeaseScope(req) : resolveRequestOrSessionLeaseScope(req, session); if (!leaseProvider) return; diff --git a/src/__tests__/cli-leased-session.test.ts b/src/__tests__/cli-leased-session.test.ts index f0b8f22c43..526a8ab1aa 100644 --- a/src/__tests__/cli-leased-session.test.ts +++ b/src/__tests__/cli-leased-session.test.ts @@ -146,6 +146,7 @@ async function leaseIdsOnRealDaemon(allocations: CapturedDaemonRequest[]): Promi daemonBaseUrl: daemon.baseUrl, daemonAuthToken: daemon.runtime.token, daemonTransport: 'http', + session: 'lease-reader', }); const leaseIds = []; for (const allocate of allocations) { @@ -164,6 +165,40 @@ async function leaseIdsOnRealDaemon(allocations: CapturedDaemonRequest[]): Promi } } +test('the Node client is refused a provider lease until it names a session', async () => { + const daemon = await startPublicDaemonRuntime({ serverMode: 'http' }); + const connection = { + daemonBaseUrl: daemon.baseUrl, + daemonAuthToken: daemon.runtime.token, + daemonTransport: 'http', + } as const; + const lease = { + tenant: 'acme', + runId: 'run-a', + leaseBackend: 'android-instance', + leaseProvider: 'proxy', + clientId: 'client-a', + } as const; + try { + await assert.rejects( + createAgentDeviceClient(connection).leases.allocate(lease), + (error: { details?: { reason?: string } }) => + error.details?.reason === 'REMOTE_SESSION_REQUIRED', + ); + + const named = createAgentDeviceClient({ ...connection, session: 'agent-a' }); + const leaseId = (await named.leases.allocate(lease)).leaseId; + assert.ok(leaseId); + const namedPerCall = await createAgentDeviceClient(connection).leases.allocate({ + ...lease, + session: 'agent-a', + }); + assert.equal(namedPerCall.leaseId, leaseId); + } finally { + await daemon.close(); + } +}); + function allocationOf(result: Awaited>): CapturedDaemonRequest { const allocate = result.calls.find((call) => call.command === 'lease_allocate'); assert.ok(allocate, result.stdout); @@ -302,6 +337,7 @@ test('a session named by flag, env or config never falls back onto the pointer', result.calls.map((call) => call.command), ['snapshot'], ); + assert.equal(result.calls[0]?.meta?.sessionExplicit, true); assert.equal(scopeOf(result.calls[0]!, 'tenantId'), undefined); assert.equal(leaseIdOf(result.calls[0]), undefined); } diff --git a/src/__tests__/lease-provider-profile-fields.test.ts b/src/__tests__/lease-provider-profile-fields.test.ts index 7733211aef..dd07a710d8 100644 --- a/src/__tests__/lease-provider-profile-fields.test.ts +++ b/src/__tests__/lease-provider-profile-fields.test.ts @@ -38,6 +38,7 @@ test('leases.allocate refuses an OS version Limrun cannot honour instead of igno daemonBaseUrl: daemon.baseUrl, daemonAuthToken: daemon.runtime.token, daemonTransport: 'http', + session: 'agent-a', }); await assert.rejects( client.leases.allocate({ diff --git a/src/provider-device-runtimes.test.ts b/src/provider-device-runtimes.test.ts index a023aba458..5ff9a3f553 100644 --- a/src/provider-device-runtimes.test.ts +++ b/src/provider-device-runtimes.test.ts @@ -42,7 +42,11 @@ test('Limrun retries an expired provider lease persisted by a daemon before rest try { firstDaemon = await startPublicDaemonRuntime({ stateDir, env }); - const client = createAgentDeviceClient({ stateDir, daemonTransport: 'http' }); + const client = createAgentDeviceClient({ + stateDir, + daemonTransport: 'http', + session: 'agent-a', + }); const lease = await client.leases.allocate({ tenant: 'team-a', runId: 'run-a', diff --git a/test/integration/provider-scenarios/cloud-webdriver-runtime.test.ts b/test/integration/provider-scenarios/cloud-webdriver-runtime.test.ts index 942133fef5..4a7ec82b11 100644 --- a/test/integration/provider-scenarios/cloud-webdriver-runtime.test.ts +++ b/test/integration/provider-scenarios/cloud-webdriver-runtime.test.ts @@ -223,6 +223,7 @@ test('packaged Cloud WebDriver cancels an abandoned source capture and keeps its try { const capture = daemon.client().capture.snapshot({ + session: 'default', signal: controller.signal, ...leaseClientOptions(lease.leaseId), }); diff --git a/test/wire-compat/ledger.json b/test/wire-compat/ledger.json index 5305517263..f4ab391d95 100644 --- a/test/wire-compat/ledger.json +++ b/test/wire-compat/ledger.json @@ -117,11 +117,10 @@ "packages/daemon/src/server/http/http-authorization.ts#readHeaderValue": "sha256:0ec16993e5d9b460ba02deb4a43bbc4c248f9697eaed9aa64003eec7d5dce6bf", "packages/daemon/src/server/http/http-authorization.ts#resolveToken": "sha256:3abcb019e1325317618045b7138e41845990af10953dd937571997cd6ec35981", "packages/daemon/src/server/http/http-rpc-envelope.ts#sendJson": "sha256:a1de00da3f1db8c98e69a85dfe2e11b626a4d2ce472840e3b8f519aa8a207a8f", - "packages/daemon/src/server/http/http-rpc-methods.ts#requestSessionFields": "sha256:a38a97be4fe63f6a0e1241722d3040219332ecb78233513ca2f178a2c8742953", - "packages/daemon/src/server/http/http-rpc-methods.ts#toDaemonRequest": "sha256:458d1568bb9d3ca3ae6f6aac47e1721489526ddeb9b5ae345a7b3328d855ba47", - "packages/daemon/src/server/http/http-rpc-methods.ts#toInstallFromSourceDaemonRequest": "sha256:8261a59a56ebb74104cb9179147f38287c98c94a3deb618f331bf6fec4ff5850", - "packages/daemon/src/server/http/http-rpc-methods.ts#toLeaseDaemonRequest": "sha256:649db7b268ca9b8b95d1d2b7b22b349f5938711533347ab82a509f346da3aee8", - "packages/daemon/src/server/http/http-rpc-methods.ts#toReleaseMaterializedPathsDaemonRequest": "sha256:708736ca03d6a3fc449bb408382dbd509e490de1f84ecc8771fd77d4adda0154", + "packages/daemon/src/server/http/http-rpc-methods.ts#toDaemonRequest": "sha256:d2dbb0aad178fd1100faaa64a1c2c6c76e860e705e74f04b34220b6e6d5c46dc", + "packages/daemon/src/server/http/http-rpc-methods.ts#toInstallFromSourceDaemonRequest": "sha256:bd6fd615ff8bcf6b789a4ae5a7b5d09ace93c39948452fb187dbb3a8ea06be41", + "packages/daemon/src/server/http/http-rpc-methods.ts#toLeaseDaemonRequest": "sha256:9bb2ab458ec44da3281be5dab7237d963fbd6f7c209995d6ef81fa28892155db", + "packages/daemon/src/server/http/http-rpc-methods.ts#toReleaseMaterializedPathsDaemonRequest": "sha256:a600285c173c95a75093c9eae65ee96df63d9524f7cc96c0479cf9884069f17c", "packages/daemon/src/server/http/http-rpc-envelope.ts#writeProgressEnvelope": "sha256:7be778b3902c72d7aa6282c16dc7106fdd64e99c0b5daf9f413ff5dd3fb044f1", "packages/daemon/src/server/http/http-rpc-envelope.ts#writeRpcResponseEnvelope": "sha256:7a8155e9fcbb53485489728250a85109b5dab0d96cdd4aa8b7e7eb87a4898ddb", "packages/daemon/src/core/session-tenant-scope.ts#TenantSessionNamespace": "sha256:a6613d22933caa053e479f05b7f016664f6be8d0afdbbf81e578ff497fc1b13d", @@ -191,9 +190,10 @@ "packages/daemon-client/src/daemon-client-transport.ts#isRemoteInstanceMismatchResponse": "sha256:7670f48fe0fc7344f8a000cd303e49788ec7ea9d1c729bcdc2af73ff02dae04d", "packages/daemon-client/src/daemon-client-transport.ts#isRemoteInstanceMismatch": "sha256:6d4cfdd36bd44686d2a48990491faf63ba9117fb760f982fd72922c99be8daee", "packages/contracts/src/lease-scope.ts#buildDaemonRpcRequest": "sha256:bead87907afdad0aa685126ef191a70d7e99495ce5d11dd1c0505d07623b9137", - "packages/contracts/src/lease-scope.ts#buildLeaseRpcParams": "sha256:365490a0fe1833d7ce8b0d3f5425462608a85119b12c1a2c4f16bb83b5d28e06", + "packages/contracts/src/lease-scope.ts#buildLeaseRpcParams": "sha256:f5941df772df21da5add017a7a358bdd1c9a94dd059ad3cf3babe348a0cfcd4e", "packages/contracts/src/lease-scope.ts#isLeaseRpcCommand": "sha256:955dc1b73462d40593e5728dbcbaeec1307944b08262a22627e84cc5dc507e8f", - "packages/contracts/src/lease-scope.ts#leaseRpcMethodForCommand": "sha256:3613555c6002c23ab5bec4b98bc1b4d6947014b52fa100176177f955cf762881" + "packages/contracts/src/lease-scope.ts#leaseRpcMethodForCommand": "sha256:3613555c6002c23ab5bec4b98bc1b4d6947014b52fa100176177f955cf762881", + "packages/daemon/src/core/request-session-intake.ts#resolveRequestSessionAtIntake": "sha256:876d492cd428fd934554ce7c93a0a9d1108a3c7d997fb9c195d5590cb4020263" }, "compatibleChanges": [ { @@ -227,24 +227,24 @@ "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. The signature also names the client request as DaemonWireRequest and the response as the kernel DaemonResponse instead of the daemon package aliases; only compile-time type names moved. It now composes the envelope through buildDaemonRpcRequest in @agent-device/contracts/lease-scope, which takes the request id from its caller; the client supplies the id it supplied before (the request meta id, else a fresh one), so envelope construction preserves method, params and id. Separately, credential fingerprint lookup now trims the provider name on both client and daemon. A whitespace-spelled provider can therefore add a fingerprint that the released client omitted; this changes fingerprint presence, not the envelope or optional parameter contract." }, { - "declaration": "packages/daemon/src/server/http/http-rpc-methods.ts#requestSessionFields", - "digest": "sha256:a38a97be4fe63f6a0e1241722d3040219332ecb78233513ca2f178a2c8742953", - "rationale": "#4171 records a blank or omitted session in daemon-only request state; both now become default. Released protocol-2 clients send a literal session name, so this check does not cover them and their payload is read as before, except that a blank name is now refused like an omitted one. A caller that names no session on a provider lease gets a normal INVALID_ARGS error with reason REMOTE_SESSION_REQUIRED, and no peer misreads a payload." + "declaration": "packages/daemon/src/core/request-session-intake.ts#resolveRequestSessionAtIntake", + "digest": "sha256:876d492cd428fd934554ce7c93a0a9d1108a3c7d997fb9c195d5590cb4020263", + "rationale": "#4171 records a request that names no session in daemon-only request state: a missing or blank session, or meta.sessionExplicit false with no named flag. A missing or blank name becomes default, and the literal default from a client stays. Lease params gain an optional sessionExplicit, which a released daemon ignores. A released client that names no session on a provider lease through the command method now gets a normal INVALID_ARGS error with reason REMOTE_SESSION_REQUIRED, and no peer misreads a payload." }, { "declaration": "packages/daemon/src/server/http/http-rpc-methods.ts#toDaemonRequest", - "digest": "sha256:458d1568bb9d3ca3ae6f6aac47e1721489526ddeb9b5ae345a7b3328d855ba47", - "rationale": "#4171 records a blank or omitted session in daemon-only request state; both now become default. Released protocol-2 clients send a literal session name, so this check does not cover them and their payload is read as before, except that a blank name is now refused like an omitted one. A caller that names no session on a provider lease gets a normal INVALID_ARGS error with reason REMOTE_SESSION_REQUIRED, and no peer misreads a payload." + "digest": "sha256:d2dbb0aad178fd1100faaa64a1c2c6c76e860e705e74f04b34220b6e6d5c46dc", + "rationale": "#4171 records a request that names no session in daemon-only request state: a missing or blank session, or meta.sessionExplicit false with no named flag. A missing or blank name becomes default, and the literal default from a client stays. Lease params gain an optional sessionExplicit, which a released daemon ignores. A released client that names no session on a provider lease through the command method now gets a normal INVALID_ARGS error with reason REMOTE_SESSION_REQUIRED, and no peer misreads a payload." }, { "declaration": "packages/daemon/src/server/http/http-rpc-methods.ts#toInstallFromSourceDaemonRequest", - "digest": "sha256:8261a59a56ebb74104cb9179147f38287c98c94a3deb618f331bf6fec4ff5850", - "rationale": "#4171 records a blank or omitted session in daemon-only request state; both now become default. Released protocol-2 clients send a literal session name, so this check does not cover them and their payload is read as before, except that a blank name is now refused like an omitted one. A caller that names no session on a provider lease gets a normal INVALID_ARGS error with reason REMOTE_SESSION_REQUIRED, and no peer misreads a payload." + "digest": "sha256:bd6fd615ff8bcf6b789a4ae5a7b5d09ace93c39948452fb187dbb3a8ea06be41", + "rationale": "#4171 records a request that names no session in daemon-only request state: a missing or blank session, or meta.sessionExplicit false with no named flag. A missing or blank name becomes default, and the literal default from a client stays. Lease params gain an optional sessionExplicit, which a released daemon ignores. A released client that names no session on a provider lease through the command method now gets a normal INVALID_ARGS error with reason REMOTE_SESSION_REQUIRED, and no peer misreads a payload." }, { "declaration": "packages/daemon/src/server/http/http-rpc-methods.ts#toLeaseDaemonRequest", - "digest": "sha256:649db7b268ca9b8b95d1d2b7b22b349f5938711533347ab82a509f346da3aee8", - "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. #4171 records a blank or omitted session in daemon-only request state; both now become default. Released protocol-2 clients send a literal session name, so this check does not cover them and their payload is read as before, except that a blank name is now refused like an omitted one. A caller that names no session on a provider lease gets a normal INVALID_ARGS error with reason REMOTE_SESSION_REQUIRED, and no peer misreads a payload." + "digest": "sha256:9bb2ab458ec44da3281be5dab7237d963fbd6f7c209995d6ef81fa28892155db", + "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. #4171 records a request that names no session in daemon-only request state: a missing or blank session, or meta.sessionExplicit false with no named flag. A missing or blank name becomes default, and the literal default from a client stays. Lease params gain an optional sessionExplicit, which a released daemon ignores. A released client that names no session on a provider lease through the command method now gets a normal INVALID_ARGS error with reason REMOTE_SESSION_REQUIRED, and no peer misreads a payload." }, { "declaration": "packages/kernel/src/errors.ts#DaemonError", @@ -488,8 +488,8 @@ }, { "declaration": "packages/contracts/src/lease-scope.ts#buildLeaseRpcParams", - "digest": "sha256:365490a0fe1833d7ce8b0d3f5425462608a85119b12c1a2c4f16bb83b5d28e06", - "rationale": "The client names its request as DaemonWireRequest (packages/contracts/src/daemon-wire-request.ts, a narrowing of the listed kernel DaemonRequest) and its response as the kernel DaemonResponse, instead of the daemon package's DaemonRequest/DaemonResponse aliases, so the daemon package is no longer a client import. The declaration's serialized fields, method names and framing are unchanged; only the compile-time type names in its signature moved, so a peer on protocol 2 parses exactly what it did." + "digest": "sha256:f5941df772df21da5add017a7a358bdd1c9a94dd059ad3cf3babe348a0cfcd4e", + "rationale": "The client names its request as DaemonWireRequest (packages/contracts/src/daemon-wire-request.ts, a narrowing of the listed kernel DaemonRequest) and its response as the kernel DaemonResponse, instead of the daemon package's DaemonRequest/DaemonResponse aliases, so the daemon package is no longer a client import. The declaration's serialized fields, method names and framing are unchanged; only the compile-time type names in its signature moved, so a peer on protocol 2 parses exactly what it did. #4171 records a request that names no session in daemon-only request state: a missing or blank session, or meta.sessionExplicit false with no named flag. A missing or blank name becomes default, and the literal default from a client stays. Lease params gain an optional sessionExplicit, which a released daemon ignores. A released client that names no session on a provider lease through the command method now gets a normal INVALID_ARGS error with reason REMOTE_SESSION_REQUIRED, and no peer misreads a payload." }, { "declaration": "packages/daemon-client/src/daemon-client-rpc.ts#resolveDaemonHttpResult", @@ -515,6 +515,11 @@ "declaration": "packages/daemon/src/server/http-errors.ts#failStreamedHttpResponse", "digest": "sha256:e4f4dbb80a2213395220dfe448ff4453cc0402a755a66fe4203a7adf6fda4948", "rationale": "Passes the whole normalized error to the details-aware status mapper instead of its code; the plain-text body is unchanged. A gone resource now answers 404 instead of 500: COMMAND_FAILED with details.reason RESOURCE_EXPIRED (an artifact, upload ticket, uploaded artifact or materialized path id the TTL registry no longer holds) maps to 404. The mapper also takes over the DEVICE_IN_USE human_control_active 423 rule, which statusCodeForDaemonError applied only to a failed response a /rpc handler returned. The rule now also reaches the REST routes and thrown /rpc failures, which answered 500: a PUT /admin/leases/ refused during a human-control hold, or a non-streamed /rpc call whose handler throws human_control_active, now answers 423. /admin/* is host-only on the loopback port and the proxy never forwards it, and the host already reads 423 for the same refusal on a returned /rpc failure. No released client branches on 404 versus 500 for these routes: downloadRemoteArtifactFromUrl treats any status >= 400 alike, the upload client fails any non-2xx and decides retries from a typed stream detail rather than the status, the /rpc client parses the JSON-RPC error envelope whatever the status and only special-cases 409, and the proxy passes the status through. The error code, message and JSON-RPC error data a peer parses are unchanged, so a protocol 2 peer reports the same failure it did before." + }, + { + "declaration": "packages/daemon/src/server/http/http-rpc-methods.ts#toReleaseMaterializedPathsDaemonRequest", + "digest": "sha256:a600285c173c95a75093c9eae65ee96df63d9524f7cc96c0479cf9884069f17c", + "rationale": "#4171 records a request that names no session in daemon-only request state: a missing or blank session, or meta.sessionExplicit false with no named flag. A missing or blank name becomes default, and the literal default from a client stays. Lease params gain an optional sessionExplicit, which a released daemon ignores. A released client that names no session on a provider lease through the command method now gets a normal INVALID_ARGS error with reason REMOTE_SESSION_REQUIRED, and no peer misreads a payload." } ] } diff --git a/test/wire-compat/surface.ts b/test/wire-compat/surface.ts index a3676629c5..63c0db8de8 100644 --- a/test/wire-compat/surface.ts +++ b/test/wire-compat/surface.ts @@ -238,12 +238,15 @@ export const WIRE_SURFACE: readonly WireSurfaceGroup[] = [ 'isCommandRpcMethod', 'methodToDaemonRequest', 'parseCommandRpcParams', - 'requestSessionFields', 'toDaemonRequest', 'toLeaseDaemonRequest', 'toInstallFromSourceDaemonRequest', 'toReleaseMaterializedPathsDaemonRequest', ), + ...from( + 'packages/daemon/src/core/request-session-intake.ts', + 'resolveRequestSessionAtIntake', + ), // Consumer side: the payload the client actually puts on the wire. A // client-only change here breaks an older daemon just as surely. ...from(CLIENT_RPC, 'buildHttpRpcPayload'), diff --git a/test/wire-compat/wire-mutations.test.ts b/test/wire-compat/wire-mutations.test.ts index bdd6d7a1eb..a2f87ac705 100644 --- a/test/wire-compat/wire-mutations.test.ts +++ b/test/wire-compat/wire-mutations.test.ts @@ -86,8 +86,8 @@ const MUTATIONS: readonly WireMutation[] = [ }, { breakClass: 'session intake: an unnamed request stops being marked as unnamed', - file: 'packages/daemon/src/server/http/http-rpc-methods.ts', - name: 'requestSessionFields', + file: 'packages/daemon/src/core/request-session-intake.ts', + name: 'resolveRequestSessionAtIntake', from: 'internal: { sessionOmitted: true }', to: 'internal: {}', }, diff --git a/website/docs/docs/aws-device-farm.md b/website/docs/docs/aws-device-farm.md index 36c6faa17c..457f6de3f3 100644 --- a/website/docs/docs/aws-device-farm.md +++ b/website/docs/docs/aws-device-farm.md @@ -86,6 +86,7 @@ Configure the client directly when your Node.js process manages the AWS credenti import { createAgentDeviceClient } from 'agent-device'; const client = createAgentDeviceClient({ + session: 'checkout-android', leaseProvider: 'aws-device-farm', platform: 'android', awsProjectArn: process.env.AWS_DEVICE_FARM_PROJECT_ARN, diff --git a/website/docs/docs/browserstack.md b/website/docs/docs/browserstack.md index 4e490741d8..e4ba8644d2 100644 --- a/website/docs/docs/browserstack.md +++ b/website/docs/docs/browserstack.md @@ -93,6 +93,7 @@ Configure the client directly when your Node.js process manages the BrowserStack import { createAgentDeviceClient } from 'agent-device'; const client = createAgentDeviceClient({ + session: 'checkout-android', leaseProvider: 'browserstack', platform: 'android', device: 'Google Pixel 8', diff --git a/website/docs/docs/client-api.md b/website/docs/docs/client-api.md index db77da44c5..bb4583e5f8 100644 --- a/website/docs/docs/client-api.md +++ b/website/docs/docs/client-api.md @@ -168,6 +168,7 @@ You drive Limrun, BrowserStack, AWS Device Farm, and TestMu AI devices with the import { createAgentDeviceClient } from 'agent-device'; const client = createAgentDeviceClient({ + session: 'checkout-android', leaseProvider: 'browserstack', providerOsVersion: '14.0', providerApp: 'bs://app-id', @@ -344,7 +345,7 @@ Domain client methods: - `client.sessions.list()`, `stateDir()`, `close()`, `saveScript()`, `artifacts()` - `client.apps.install()`, `reinstall()`, `uninstall()`, `installFromSource()`, `list()`, `open()`, `close()`, `push()`, `triggerEvent()` - `client.materializations.release()` -- `client.leases.allocate()`, `heartbeat()`, `release()`. Pass `retainOnClose: true` to `allocate()` when you release the lease yourself; session `close` then leaves it active until `release()`, expiry, or daemon shutdown. The returned lease has `retainOnClose: true` only when the daemon honored it. Keep heartbeating a retained lease between sessions: it expires after its `ttlMs` like any other lease. Pass `leaseId` (16 to 128 hex characters) to `allocate()` to name a new lease with an id the daemon has never issued, or to get back your live lease with that id when you retry with the same tenant, run, client, lease backend, and provider (and no device, or the leased one), then use the returned `leaseId`: a run that already holds a lease gets that one back, and an older daemon mints its own. `allocate()` also tells you which of those you got, on the returned lease's `created`: `true` when your request created that lease, `false` when it was already there, and omitted on `heartbeat()` or when an older daemon does not support ownership evidence. The CLI releases an allocate response it discards before recording (a spec refusal, identity refusal, or keyless replacement), unless `created` is `false`. After binding, rollback on a later preparation failure requires `created:true`. Against an older daemon that omits `created`, refused or replaced leases are released, but a bound, unrecorded lease remains until expiry if preparation fails. `allocate()` with a `leaseProvider` the daemon has no runtime for fails with `UNSUPPORTED_OPERATION`, reason `provider-not-registered`, and `details.registeredProviders`; no lease is recorded. Leases with no provider, `proxy`, or `cloud` need no runtime. +- `client.leases.allocate()`, `heartbeat()`, `release()`. A client on a lease from a lease provider must name its session, or the daemon refuses it with reason `REMOTE_SESSION_REQUIRED`. Pass `retainOnClose: true` to `allocate()` when you release the lease yourself; session `close` then leaves it active until `release()`, expiry, or daemon shutdown. The returned lease has `retainOnClose: true` only when the daemon honored it. Keep heartbeating a retained lease between sessions: it expires after its `ttlMs` like any other lease. Pass `leaseId` (16 to 128 hex characters) to `allocate()` to name a new lease with an id the daemon has never issued, or to get back your live lease with that id when you retry with the same tenant, run, client, lease backend, and provider (and no device, or the leased one), then use the returned `leaseId`: a run that already holds a lease gets that one back, and an older daemon mints its own. `allocate()` also tells you which of those you got, on the returned lease's `created`: `true` when your request created that lease, `false` when it was already there, and omitted on `heartbeat()` or when an older daemon does not support ownership evidence. The CLI releases an allocate response it discards before recording (a spec refusal, identity refusal, or keyless replacement), unless `created` is `false`. After binding, rollback on a later preparation failure requires `created:true`. Against an older daemon that omits `created`, refused or replaced leases are released, but a bound, unrecorded lease remains until expiry if preparation fails. `allocate()` with a `leaseProvider` the daemon has no runtime for fails with `UNSUPPORTED_OPERATION`, reason `provider-not-registered`, and `details.registeredProviders`; no lease is recorded. Leases with no provider, `proxy`, or `cloud` need no runtime. - `client.metro.prepare()`, `reload()` - `client.capture.snapshot()`, `screenshot()`, `diff()` - `client.interactions.gesture()`, `click()`, `press()`, `longPress()`, `swipe()`, `pan()`, `drag()`, `fling()`, `swipeGesture()`, `focus()`, `type()`, `fill()`, `scroll()`, `pinch()`, `rotateGesture()`, `transformGesture()`, `tapGesture()`, `get()`, `is()`, `find()` diff --git a/website/docs/docs/limrun.md b/website/docs/docs/limrun.md index 9fb9211e1e..3564cca3dc 100644 --- a/website/docs/docs/limrun.md +++ b/website/docs/docs/limrun.md @@ -163,7 +163,7 @@ const provider = { }; }, release: async (lease: { id: string }) => { - await createAgentDeviceClient({}).leases.release({ ...scope, leaseId: lease.id }); + await createAgentDeviceClient({ session: `lease-${lease.id}` }).leases.release({ ...scope, leaseId: lease.id }); }, }; ``` diff --git a/website/docs/docs/remote-proxy.md b/website/docs/docs/remote-proxy.md index 14dbe1df11..8804af9341 100644 --- a/website/docs/docs/remote-proxy.md +++ b/website/docs/docs/remote-proxy.md @@ -44,7 +44,7 @@ agent-device disconnect Every command on a proxy connection must name its session, with `AGENT_DEVICE_SESSION`, `--session `, or a `session` in your config. The same applies to every device-cloud connection. A command that names none fails with `INVALID_ARGS` and `details.reason: "REMOTE_SESSION_REQUIRED"`, and sends nothing to the proxy. It never falls back to the connection that another agent on the same machine saved last. This includes `connect proxy` and `open --remote-config ` with a proxy profile. The client and run that the proxy leases under come from the session name, so two agents with different names never share a lease. -The daemon enforces the same rule for direct RPC callers. An `agent_device.command`, `agent_device.install_from_source`, or `agent_device.lease.*` request with a missing or blank `session` param that would allocate, use, or release a lease from a lease provider fails with the same reason before anything is allocated or dispatched. Requests with no lease provider keep their default session. +The daemon enforces the same rule for direct RPC callers. An `agent_device.command`, `agent_device.install_from_source`, or `agent_device.lease.*` request that would allocate, use, or release a lease from a lease provider fails with the same reason before anything is allocated or dispatched if it names no session. A request names no session when its `session` param is missing or blank, or when `meta.sessionExplicit` is `false` and no `flags.session` is set. `createAgentDeviceClient` sends that fact when you do not pass `session`. Requests with no lease provider keep their default session. `--daemon-auth-token ` works instead of the environment variable, but it authenticates only the command you pass it to. Later commands need the token again through the environment variable, a `daemonAuthToken` entry in your remote config profile, or the flag on each command. diff --git a/website/docs/docs/revyl.md b/website/docs/docs/revyl.md index f5c1ed6c14..ae54c9e190 100644 --- a/website/docs/docs/revyl.md +++ b/website/docs/docs/revyl.md @@ -134,6 +134,7 @@ const host = await startAgentDevice({ }); const scope = { + session: 'checkout-ios', tenant: 'revyl', runId: process.env.GITHUB_RUN_ID ?? 'local-run', leaseBackend: 'ios-instance', diff --git a/website/docs/docs/testmu.md b/website/docs/docs/testmu.md index 1aa95a632b..4433c2c06a 100644 --- a/website/docs/docs/testmu.md +++ b/website/docs/docs/testmu.md @@ -158,6 +158,7 @@ The daemon reads `LT_USERNAME` and `LT_ACCESS_KEY` from its environment, unless import { createAgentDeviceClient } from 'agent-device'; const scope = { + session: 'checkout-ios', tenant: 'testmu', runId: process.env.GITHUB_RUN_ID ?? 'local-run', leaseBackend: 'ios-instance', From 7644201fb283d0c59c6a165bb75ca94805484739 Mon Sep 17 00:00:00 2001 From: Adam Rowe Date: Mon, 2 Nov 2026 18:12:56 -0500 Subject: [PATCH 7/9] fix(contracts): forward a session named in flags on the lease RPC The lease RPC projection dropped flags.session and forwarded sessionExplicit false, so a caller that named its session in flags was refused there but not on the command method. The route now forwards the shared predicate's answer. Also cover the lease method in the real-daemon test and document where each method reads the fact. --- .../src/__tests__/lease-scope-rpc.test.ts | 33 +++++++++++++++++++ packages/contracts/src/lease-scope.ts | 8 +++-- .../daemon-runtime-leased-session.test.ts | 29 ++++++++++------ test/wire-compat/ledger.json | 4 +-- website/docs/docs/remote-proxy.md | 2 +- 5 files changed, 61 insertions(+), 15 deletions(-) create mode 100644 packages/contracts/src/__tests__/lease-scope-rpc.test.ts diff --git a/packages/contracts/src/__tests__/lease-scope-rpc.test.ts b/packages/contracts/src/__tests__/lease-scope-rpc.test.ts new file mode 100644 index 0000000000..44a5aaca90 --- /dev/null +++ b/packages/contracts/src/__tests__/lease-scope-rpc.test.ts @@ -0,0 +1,33 @@ +import { expect, test } from 'vitest'; +import { buildDaemonRpcRequest } from '../lease-scope.ts'; + +function leaseRpcParams( + flags: { session?: string }, + meta: { sessionExplicit?: boolean }, +): Record { + return buildDaemonRpcRequest( + { + token: 'token', + session: 'default', + command: 'lease_allocate', + positionals: [], + flags, + meta: { tenantId: 'acme', runId: 'run-a', leaseBackend: 'android-instance', ...meta }, + }, + { id: 'id', includeTokenParam: false }, + ).params as Record; +} + +test.each([ + ['a client that named none', {}, { sessionExplicit: false }, false], + ['a client that named one in its meta', {}, { sessionExplicit: true }, true], + [ + 'a client that named one in its flags', + { session: 'default' }, + { sessionExplicit: false }, + true, + ], + ['a caller that predates the fact', {}, {}, undefined], +])('the lease method carries what %s said about its session', (_label, flags, meta, expected) => { + expect(leaseRpcParams(flags, meta).sessionExplicit).toBe(expected); +}); diff --git a/packages/contracts/src/lease-scope.ts b/packages/contracts/src/lease-scope.ts index e38718685e..0b91775fda 100644 --- a/packages/contracts/src/lease-scope.ts +++ b/packages/contracts/src/lease-scope.ts @@ -1,4 +1,8 @@ -import type { DaemonResponse, LeaseBackend } from '@agent-device/kernel/contracts'; +import { + namesSessionExplicitly, + type DaemonResponse, + type LeaseBackend, +} from '@agent-device/kernel/contracts'; import { AppError } from '@agent-device/kernel/errors'; import { stripUndefined } from '@agent-device/kernel/record'; import type { CloudProviderProfileFields } from './remote-config-fields.ts'; @@ -619,6 +623,6 @@ function buildLeaseRpcParams( }), ...(req.meta?.sessionExplicit === undefined ? {} - : { sessionExplicit: req.meta.sessionExplicit }), + : { sessionExplicit: namesSessionExplicitly(req) }), }; } diff --git a/packages/daemon/src/server/http/daemon-runtime-leased-session.test.ts b/packages/daemon/src/server/http/daemon-runtime-leased-session.test.ts index 47b0ca807e..65efb8af45 100644 --- a/packages/daemon/src/server/http/daemon-runtime-leased-session.test.ts +++ b/packages/daemon/src/server/http/daemon-runtime-leased-session.test.ts @@ -92,17 +92,26 @@ test.each([ expect(other.result?.ok, JSON.stringify(other)).toBe(true); }); -test('a client that names no session is refused before a provider lease is allocated', async () => { - const { backend: leaseBackend, ...scope } = PROXY_SCOPE; - - const refused = await rpc('agent_device.command', { - session: 'default', - command: 'lease_allocate', - positionals: [], - meta: { ...scope, leaseBackend, sessionExplicit: false }, - }); +test.each([ + [ + 'the command method', + () => { + const { backend: leaseBackend, ...scope } = PROXY_SCOPE; + return rpc('agent_device.command', { + session: 'default', + command: 'lease_allocate', + positionals: [], + meta: { ...scope, leaseBackend, sessionExplicit: false }, + }); + }, + ], + [ + 'the lease method', + () => allocate({ ...PROXY_SCOPE, session: 'default', sessionExplicit: false }), + ], +])('a client that names no session through %s is refused', async (_label, send) => { + expectSessionRequired(await send()); - expectSessionRequired(refused); const other = await allocate({ ...PROXY_SCOPE, clientId: 'client-b', session: 'agent-b' }); expect(other.result?.ok, JSON.stringify(other)).toBe(true); }); diff --git a/test/wire-compat/ledger.json b/test/wire-compat/ledger.json index f4ab391d95..21537964f9 100644 --- a/test/wire-compat/ledger.json +++ b/test/wire-compat/ledger.json @@ -190,7 +190,7 @@ "packages/daemon-client/src/daemon-client-transport.ts#isRemoteInstanceMismatchResponse": "sha256:7670f48fe0fc7344f8a000cd303e49788ec7ea9d1c729bcdc2af73ff02dae04d", "packages/daemon-client/src/daemon-client-transport.ts#isRemoteInstanceMismatch": "sha256:6d4cfdd36bd44686d2a48990491faf63ba9117fb760f982fd72922c99be8daee", "packages/contracts/src/lease-scope.ts#buildDaemonRpcRequest": "sha256:bead87907afdad0aa685126ef191a70d7e99495ce5d11dd1c0505d07623b9137", - "packages/contracts/src/lease-scope.ts#buildLeaseRpcParams": "sha256:f5941df772df21da5add017a7a358bdd1c9a94dd059ad3cf3babe348a0cfcd4e", + "packages/contracts/src/lease-scope.ts#buildLeaseRpcParams": "sha256:25b138457410480048ee3899ce758cdd80bd7a4d11dc6e025f1365e7e8729116", "packages/contracts/src/lease-scope.ts#isLeaseRpcCommand": "sha256:955dc1b73462d40593e5728dbcbaeec1307944b08262a22627e84cc5dc507e8f", "packages/contracts/src/lease-scope.ts#leaseRpcMethodForCommand": "sha256:3613555c6002c23ab5bec4b98bc1b4d6947014b52fa100176177f955cf762881", "packages/daemon/src/core/request-session-intake.ts#resolveRequestSessionAtIntake": "sha256:876d492cd428fd934554ce7c93a0a9d1108a3c7d997fb9c195d5590cb4020263" @@ -488,7 +488,7 @@ }, { "declaration": "packages/contracts/src/lease-scope.ts#buildLeaseRpcParams", - "digest": "sha256:f5941df772df21da5add017a7a358bdd1c9a94dd059ad3cf3babe348a0cfcd4e", + "digest": "sha256:25b138457410480048ee3899ce758cdd80bd7a4d11dc6e025f1365e7e8729116", "rationale": "The client names its request as DaemonWireRequest (packages/contracts/src/daemon-wire-request.ts, a narrowing of the listed kernel DaemonRequest) and its response as the kernel DaemonResponse, instead of the daemon package's DaemonRequest/DaemonResponse aliases, so the daemon package is no longer a client import. The declaration's serialized fields, method names and framing are unchanged; only the compile-time type names in its signature moved, so a peer on protocol 2 parses exactly what it did. #4171 records a request that names no session in daemon-only request state: a missing or blank session, or meta.sessionExplicit false with no named flag. A missing or blank name becomes default, and the literal default from a client stays. Lease params gain an optional sessionExplicit, which a released daemon ignores. A released client that names no session on a provider lease through the command method now gets a normal INVALID_ARGS error with reason REMOTE_SESSION_REQUIRED, and no peer misreads a payload." }, { diff --git a/website/docs/docs/remote-proxy.md b/website/docs/docs/remote-proxy.md index 8804af9341..c1fd5a2abe 100644 --- a/website/docs/docs/remote-proxy.md +++ b/website/docs/docs/remote-proxy.md @@ -44,7 +44,7 @@ agent-device disconnect Every command on a proxy connection must name its session, with `AGENT_DEVICE_SESSION`, `--session `, or a `session` in your config. The same applies to every device-cloud connection. A command that names none fails with `INVALID_ARGS` and `details.reason: "REMOTE_SESSION_REQUIRED"`, and sends nothing to the proxy. It never falls back to the connection that another agent on the same machine saved last. This includes `connect proxy` and `open --remote-config ` with a proxy profile. The client and run that the proxy leases under come from the session name, so two agents with different names never share a lease. -The daemon enforces the same rule for direct RPC callers. An `agent_device.command`, `agent_device.install_from_source`, or `agent_device.lease.*` request that would allocate, use, or release a lease from a lease provider fails with the same reason before anything is allocated or dispatched if it names no session. A request names no session when its `session` param is missing or blank, or when `meta.sessionExplicit` is `false` and no `flags.session` is set. `createAgentDeviceClient` sends that fact when you do not pass `session`. Requests with no lease provider keep their default session. +The daemon enforces the same rule for direct RPC callers. An `agent_device.command`, `agent_device.install_from_source`, or `agent_device.lease.*` request that would allocate, use, or release a lease from a lease provider fails with the same reason before anything is allocated or dispatched if it names no session. A request names no session when its `session` param is missing or blank. An `agent_device.command` request also names none when `meta.sessionExplicit` is `false` and no `flags.session` is set, and an `agent_device.lease.*` request when its `sessionExplicit` param is `false`. `agent_device.install_from_source` reads only `session`. `createAgentDeviceClient` sends that fact when you do not pass `session`. Requests with no lease provider keep their default session. `--daemon-auth-token ` works instead of the environment variable, but it authenticates only the command you pass it to. Later commands need the token again through the environment variable, a `daemonAuthToken` entry in your remote config profile, or the flag on each command. From 0dc46ec9521cb8df57e62a9c31cfef75a838d8c5 Mon Sep 17 00:00:00 2001 From: Adam Rowe Date: Mon, 2 Nov 2026 18:23:14 -0500 Subject: [PATCH 8/9] refactor(kernel): home the session-naming predicate in the record module The lease RPC builder needs the predicate, and importing it from the contracts hub made three entry surfaces evaluate more modules. The record module is already in those closures. --- packages/command-registry/src/batch.ts | 3 +-- packages/contracts/src/lease-scope.ts | 8 ++------ .../src/core/explicit-session-resolution.ts | 4 ++-- .../daemon/src/core/request-session-intake.ts | 2 +- .../handlers/record-runtime-request.ts | 2 +- .../src/sessions/handlers/record-runtime.ts | 2 +- .../src/sessions/handlers/session-appstate.ts | 2 +- .../daemon/src/sessions/session-routing.ts | 2 +- packages/kernel/src/contracts.ts | 18 ------------------ ...s.test.ts => record-session-naming.test.ts} | 2 +- packages/kernel/src/record.ts | 18 ++++++++++++++++++ 11 files changed, 29 insertions(+), 34 deletions(-) rename packages/kernel/src/{contracts.test.ts => record-session-naming.test.ts} (92%) diff --git a/packages/command-registry/src/batch.ts b/packages/command-registry/src/batch.ts index ca1973fa89..3c6ff43547 100644 --- a/packages/command-registry/src/batch.ts +++ b/packages/command-registry/src/batch.ts @@ -6,7 +6,6 @@ import { type DaemonResponse, type ResponseLevel, isNonDefaultResponseLevel, - namesSessionExplicitly, } from '@agent-device/kernel/contracts'; import { AppError, @@ -14,7 +13,7 @@ import { BOUNDED_COMPOSITE_RESOURCE_UNSUPPORTED_REASON, type DaemonError, } from '@agent-device/kernel/errors'; -import { isRecord } from '@agent-device/kernel/record'; +import { isRecord, namesSessionExplicitly } from '@agent-device/kernel/record'; import { DEFAULT_BATCH_MAX_STEPS, assertBatchStepCount, diff --git a/packages/contracts/src/lease-scope.ts b/packages/contracts/src/lease-scope.ts index 0b91775fda..a850d9e4ff 100644 --- a/packages/contracts/src/lease-scope.ts +++ b/packages/contracts/src/lease-scope.ts @@ -1,10 +1,6 @@ -import { - namesSessionExplicitly, - type DaemonResponse, - type LeaseBackend, -} from '@agent-device/kernel/contracts'; +import type { DaemonResponse, LeaseBackend } from '@agent-device/kernel/contracts'; import { AppError } from '@agent-device/kernel/errors'; -import { stripUndefined } from '@agent-device/kernel/record'; +import { namesSessionExplicitly, stripUndefined } from '@agent-device/kernel/record'; import type { CloudProviderProfileFields } from './remote-config-fields.ts'; import type { CommandFlags } from './command-flags.ts'; import type { DaemonWireRequest } from './daemon-wire-request.ts'; diff --git a/packages/daemon/src/core/explicit-session-resolution.ts b/packages/daemon/src/core/explicit-session-resolution.ts index b33ee79512..2c8fb58d2f 100644 --- a/packages/daemon/src/core/explicit-session-resolution.ts +++ b/packages/daemon/src/core/explicit-session-resolution.ts @@ -1,6 +1,6 @@ import type { DaemonWireRequest } from '@agent-device/contracts/command'; import { shellQuoteIfNeeded } from '@agent-device/kernel/device-shell'; -import { namesSessionExplicitly } from '@agent-device/kernel/contracts'; +import { namesSessionExplicitly } from '@agent-device/kernel/record'; import { AppError, namedSessionNotFoundDetails, @@ -41,7 +41,7 @@ export function namedSessionNotFoundError(sessionAddress: string): AppError { * Two shapes keep the path they chose: a request that never named a session, which is using the * daemon sessionlessly by design, and one that names a device selector as well, which states its * target outright and needs no session to supply one. The predicate deciding the first is - * `namesSessionExplicitly` (`@agent-device/kernel/contracts`), traced from what the request carries + * `namesSessionExplicitly` (`@agent-device/kernel/record`), traced from what the request carries * rather than from the session string. */ export function requireNamedSessionHoldsDevice( diff --git a/packages/daemon/src/core/request-session-intake.ts b/packages/daemon/src/core/request-session-intake.ts index 4d28b85bef..aa669aca05 100644 --- a/packages/daemon/src/core/request-session-intake.ts +++ b/packages/daemon/src/core/request-session-intake.ts @@ -1,4 +1,4 @@ -import { namesSessionExplicitly } from '@agent-device/kernel/contracts'; +import { namesSessionExplicitly } from '@agent-device/kernel/record'; import type { DaemonRequest } from './daemon-request.ts'; /** diff --git a/packages/daemon/src/sessions/handlers/record-runtime-request.ts b/packages/daemon/src/sessions/handlers/record-runtime-request.ts index a3fc5920d2..02ffa78915 100644 --- a/packages/daemon/src/sessions/handlers/record-runtime-request.ts +++ b/packages/daemon/src/sessions/handlers/record-runtime-request.ts @@ -7,7 +7,7 @@ import { import { retiredScreenshotMaxSizeFlagError } from '@agent-device/contracts/capture'; import { AppError } from '@agent-device/kernel/errors'; import type { DaemonRequest } from '../../core/daemon-request.ts'; -import { namesSessionExplicitly } from '@agent-device/kernel/contracts'; +import { namesSessionExplicitly } from '@agent-device/kernel/record'; const IOS_DEVICE_RECORD_MIN_FPS = 1; const IOS_DEVICE_RECORD_MAX_FPS = 120; diff --git a/packages/daemon/src/sessions/handlers/record-runtime.ts b/packages/daemon/src/sessions/handlers/record-runtime.ts index ba22819114..4e656c3f9a 100644 --- a/packages/daemon/src/sessions/handlers/record-runtime.ts +++ b/packages/daemon/src/sessions/handlers/record-runtime.ts @@ -18,7 +18,7 @@ import { } from '@agent-device/contracts/screen-recording-runtime-plan'; import { isWholeScreenRecordingScope } from '@agent-device/contracts/recording'; import { AppError, normalizeError } from '@agent-device/kernel/errors'; -import { namesSessionExplicitly } from '@agent-device/kernel/contracts'; +import { namesSessionExplicitly } from '@agent-device/kernel/record'; import { deviceIdentityFlag, type DeviceIdentity, diff --git a/packages/daemon/src/sessions/handlers/session-appstate.ts b/packages/daemon/src/sessions/handlers/session-appstate.ts index d83f3b9b2a..76b28d6713 100644 --- a/packages/daemon/src/sessions/handlers/session-appstate.ts +++ b/packages/daemon/src/sessions/handlers/session-appstate.ts @@ -1,5 +1,5 @@ import type { DaemonResponse } from '@agent-device/kernel/contracts'; -import { namesSessionExplicitly } from '@agent-device/kernel/contracts'; +import { namesSessionExplicitly } from '@agent-device/kernel/record'; import { isApplePlatform } from '@agent-device/kernel/device'; import { hasExplicitDeviceSelector } from '../../core/device/device-selector-intent.ts'; import { diff --git a/packages/daemon/src/sessions/session-routing.ts b/packages/daemon/src/sessions/session-routing.ts index 4caed91518..a7c3adf4b6 100644 --- a/packages/daemon/src/sessions/session-routing.ts +++ b/packages/daemon/src/sessions/session-routing.ts @@ -12,7 +12,7 @@ import type { DaemonRequest } from '../core/daemon-request.ts'; import type { SessionRef, SessionState } from '../core/session-state.ts'; import type { SessionScope } from '@agent-device/contracts/session'; import { SessionStore } from '../core/session-store.ts'; -import { namesSessionExplicitly } from '@agent-device/kernel/contracts'; +import { namesSessionExplicitly } from '@agent-device/kernel/record'; import { listSessionSelectorConflicts, type SessionSelectorConflict, diff --git a/packages/kernel/src/contracts.ts b/packages/kernel/src/contracts.ts index a46848f172..8e034226ba 100644 --- a/packages/kernel/src/contracts.ts +++ b/packages/kernel/src/contracts.ts @@ -256,24 +256,6 @@ export function errorResponse( export const NO_ACTIVE_SESSION_MESSAGE = 'No active session. Run open first.'; -/** - * Whether the caller named the session it wants, traced from what the request carries rather than - * from the session string. Every request has a session *name*, because an unnamed one inherits the - * workspace's own session address, so comparing names cannot answer this. Both carriers count: - * every client sets `meta.sessionExplicit`, and the commands whose own input schema declares - * `--session` also deliver the value in `flags.session`. The flag bag reaches this predicate in - * both shapes the wire carries it in: a command's declared flag type, or a generic record from a - * runner that merges flags (batch steps); the string check below is what decides either way. - */ -export function namesSessionExplicitly(request: { - flags?: { session?: unknown } | Record | undefined; - meta?: { sessionExplicit?: boolean } | undefined; -}): boolean { - if (request.meta?.sessionExplicit === true) return true; - const value = request.flags?.session; - return typeof value === 'string' && value.trim().length > 0; -} - /** * Shared "No active session. Run open first." failure used by handlers that require * an open session before dispatching. diff --git a/packages/kernel/src/contracts.test.ts b/packages/kernel/src/record-session-naming.test.ts similarity index 92% rename from packages/kernel/src/contracts.test.ts rename to packages/kernel/src/record-session-naming.test.ts index 45976dd723..f182e9b904 100644 --- a/packages/kernel/src/contracts.test.ts +++ b/packages/kernel/src/record-session-naming.test.ts @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import { test } from 'vitest'; -import { namesSessionExplicitly } from './contracts.ts'; +import { namesSessionExplicitly } from './record.ts'; test('a client that set the explicit-session meta names its session', () => { assert.equal(namesSessionExplicitly({ meta: { sessionExplicit: true } }), true); diff --git a/packages/kernel/src/record.ts b/packages/kernel/src/record.ts index d7048f2654..784b37aa8f 100644 --- a/packages/kernel/src/record.ts +++ b/packages/kernel/src/record.ts @@ -266,6 +266,24 @@ type WithoutUndefined> = { [K in keyof T as undefined extends T[K] ? K : never]?: Exclude; }; +/** + * Whether the caller named the session it wants, traced from what the request carries rather than + * from the session string. Every request has a session *name*, because an unnamed one inherits the + * workspace's own session address, so comparing names cannot answer this. Both carriers count: + * every client sets `meta.sessionExplicit`, and the commands whose own input schema declares + * `--session` also deliver the value in `flags.session`. The flag bag reaches this predicate in + * both shapes the wire carries it in: a command's declared flag type, or a generic record from a + * runner that merges flags (batch steps); the string check below is what decides either way. + */ +export function namesSessionExplicitly(request: { + flags?: { session?: unknown } | Record | undefined; + meta?: { sessionExplicit?: boolean } | undefined; +}): boolean { + if (request.meta?.sessionExplicit === true) return true; + const value = request.flags?.session; + return typeof value === 'string' && value.trim().length > 0; +} + export function stripUndefined>(value: T): WithoutUndefined { const output = {} as WithoutUndefined; for (const [key, current] of Object.entries(value)) { From 071a06ed26585e0e9ddcdcb6b863f37dfb977c5c Mon Sep 17 00:00:00 2001 From: Adam Rowe Date: Mon, 2 Nov 2026 19:46:23 -0500 Subject: [PATCH 9/9] docs(daemon): say batch steps also carry the session-omitted marker --- packages/daemon/src/core/daemon-request.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/daemon/src/core/daemon-request.ts b/packages/daemon/src/core/daemon-request.ts index 1857565374..41e9b9394d 100644 --- a/packages/daemon/src/core/daemon-request.ts +++ b/packages/daemon/src/core/daemon-request.ts @@ -41,8 +41,9 @@ type DaemonRequestInternal = ReplayDispatchOptions & { retainDeviceExecutionLock?: (deviceId: string) => Promise; admittedLease?: DeviceLease; /** - * The caller named no session. Set only by `resolveRequestSessionAtIntake`, because the defaulted - * name cannot be told apart from a chosen one afterwards. + * The caller named no session. Set by `resolveRequestSessionAtIntake`, because the defaulted name + * cannot be told apart from a chosen one afterwards, and copied onto batch steps by + * `session-batch`. */ sessionOmitted?: true; /**