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/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/__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 3f6e2b9966..a850d9e4ff 100644 --- a/packages/contracts/src/lease-scope.ts +++ b/packages/contracts/src/lease-scope.ts @@ -1,6 +1,6 @@ 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'; @@ -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 , 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.", + }, + ); +} + export function leaseScopeFromRequest(req: LeaseRequestLike): LeaseScope { return stripUndefined({ tenantId: req.meta?.tenantId ?? readFlagString(req.flags, 'tenant'), @@ -598,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: namesSessionExplicitly(req) }), + }; } 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/__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/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 8cd237cc37..41e9b9394d 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. 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; /** * 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/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.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..aa669aca05 --- /dev/null +++ b/packages/daemon/src/core/request-session-intake.ts @@ -0,0 +1,24 @@ +import { namesSessionExplicitly } from '@agent-device/kernel/record'; +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/__tests__/http-server-rpc-validation.test.ts b/packages/daemon/src/server/__tests__/http-server-rpc-validation.test.ts index f8ef230be4..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 @@ -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' }, @@ -355,6 +357,50 @@ test('local command RPC keeps host paths unrestricted', async (t) => { } }); +test.for([ + ['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, resolvedSession, 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.length, 1); + assert.equal(received[0]?.session, resolvedSession); + 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..e5d925e16a --- /dev/null +++ b/packages/daemon/src/server/__tests__/request-execution-scope-unnamed-session.test.ts @@ -0,0 +1,132 @@ +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.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-'); + 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 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'); + const scope = await createTestRequestScope({ + req: makeRequest({ internal: { sessionOmitted: true } }), + sessionStore, + leaseRegistry, + }); + + publishProviderLeasedDefaultSession(sessionStore, leaseRegistry); + + 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', '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-'); + 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 new file mode 100644 index 0000000000..65efb8af45 --- /dev/null +++ b/packages/daemon/src/server/http/daemon-runtime-leased-session.test.ts @@ -0,0 +1,216 @@ +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 })); + + // 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.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()); + + 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'); + + 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 for its session 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)).toBe('DEVICE_NOT_FOUND'); + 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..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; @@ -45,7 +46,7 @@ const SUPPORTED_RPC_METHODS = new Set([ function toDaemonRequest(params: CommandRpcParams, headers: IncomingHttpHeaders): DaemonRequest { return { token: resolveToken(params as Record, headers), - session: params.session ?? 'default', + ...resolveRequestSessionAtIntake(params), command: params.command ?? '', positionals: params.positionals ?? [], input: params.input, @@ -148,7 +149,10 @@ function toLeaseDaemonRequest( ): DaemonRequest { return { token: resolveToken(params, headers), - session: readStringParam(params, 'session') ?? 'default', + ...resolveRequestSessionAtIntake({ + session: readStringParam(params, 'session'), + meta: { sessionExplicit: readBooleanParam(params, 'sessionExplicit') }, + }), command, positionals: [], flags: command === 'lease_allocate' ? readLeaseAllocateProviderFlags(params) : undefined, @@ -226,7 +230,7 @@ function toInstallFromSourceDaemonRequest( } return { token: resolveToken(params, headers), - session: readStringParam(params, 'session') ?? 'default', + ...resolveRequestSessionAtIntake({ session: readStringParam(params, 'session') }), command: 'install_source', positionals: [], flags: { platform }, @@ -249,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.test.ts b/packages/daemon/src/server/http/transport.test.ts index 509ba0850a..a9f7d16493 100644 --- a/packages/daemon/src/server/http/transport.test.ts +++ b/packages/daemon/src/server/http/transport.test.ts @@ -83,6 +83,38 @@ test('Provider-backed integration daemon socket transport frames requests and no } }); +test.for([ + ['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, resolvedSession, sessionOmitted], t) => { + if (await skipWhenLoopbackUnavailable(t)) return; + + 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.length, 1); + assert.equal(observedRequests[0]?.session, resolvedSession); + 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. diff --git a/packages/daemon/src/server/http/transport.ts b/packages/daemon/src/server/http/transport.ts index bb7a409bec..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,7 +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; - return request as DaemonRequest; + return { ...request, ...resolveRequestSessionAtIntake(request) } 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 d652021267..ed4caec3e0 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. @@ -374,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/__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/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/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 b035adc70a..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, @@ -15,7 +16,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 +172,25 @@ 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 } = ignoresSessionLease(req.command) + ? resolveLeaseScope(req) + : 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/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)) { 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/__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/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/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/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 b1c1bda441..5972b0a5e7 100644 --- a/test/wire-compat/ledger.json +++ b/test/wire-compat/ledger.json @@ -117,10 +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#toDaemonRequest": "sha256:f3861d2d4f67f66e83800616926f6bbb0595de4ecbb0072b5b78971ebeb0705b", - "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#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", @@ -190,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:25b138457410480048ee3899ce758cdd80bd7a4d11dc6e025f1365e7e8729116", "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": [ { @@ -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,10 +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/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: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: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: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: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", @@ -288,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", @@ -472,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: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." }, { "declaration": "packages/daemon-client/src/daemon-client-rpc.ts#resolveDaemonHttpResult", @@ -499,6 +515,11 @@ "declaration": "packages/daemon/src/server/http-errors.ts#failStreamedHttpResponse", "digest": "sha256:3e6b642cb41b88ab1e6f3269ebd14918f912cbecf09f40e9b591fdd28a2ba489", "rationale": "Compatible HTTP error repair under ADR 0006. Status: the helper passes the whole normalized error to the details-aware status mapper instead of its code. 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. Body: before headers, the helper no longer writes the plain-text error message. It removes stale content-length and content-disposition headers and sends the existing REST JSON error shape ({ ok: false, error, code, reason?, hint? }) that the other REST routes already use. Previously a diagnostics stream open failure could keep its successful-response length and leave the error body incomplete. After headers, it still destroys the incomplete transfer, and it ignores an already destroyed response. No released client branches on 404 versus 500 or parses this body: downloadRemoteArtifactFromUrl treats any status >= 400 alike and keeps the body only as an opaque details.body string, the diagnostics client rejects non-success statuses before reading records, 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. Routes, successful responses and authorization 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 1a5baf4c99..63c0db8de8 100644 --- a/test/wire-compat/surface.ts +++ b/test/wire-compat/surface.ts @@ -243,6 +243,10 @@ export const WIRE_SURFACE: readonly WireSurfaceGroup[] = [ '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 b2e04447f1..a2f87ac705 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/core/request-session-intake.ts', + name: 'resolveRequestSessionAtIntake', + 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/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 f7ee37a8fc..cf43675047 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 graceful 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. On iOS simulators, a healthy idle XCTest runner can stay available between those sessions. Releasing or expiring the lease stops its runner unless a session outside the lease has taken over the device. 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 graceful 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. On iOS simulators, a healthy idle XCTest runner can stay available between those sessions. Releasing or expiring the lease stops its runner unless a session outside the lease has taken over the device. 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 4f7819c217..0e6cc7cd46 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`, `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. `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. diff --git a/website/docs/docs/revyl.md b/website/docs/docs/revyl.md index 46d0c84f88..49258f2f39 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',