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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions packages/command-registry/src/batch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,14 @@ import {
type DaemonResponse,
type ResponseLevel,
isNonDefaultResponseLevel,
namesSessionExplicitly,
} from '@agent-device/kernel/contracts';
import {
AppError,
asAppError,
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,
Expand Down
6 changes: 6 additions & 0 deletions packages/command-registry/src/daemon-command-descriptor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,12 @@ export type DaemonCommandDescriptor<TRequest = DispatchedCommand> = {
sessionKind?: SessionCommandKind;
refFrameEffect?: DaemonRefFrameEffect<TRequest>;
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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -336,6 +336,7 @@ export const SESSION_OPERATION_COMMAND_DESCRIPTORS = [
daemon: {
route: 'session',
refFrameEffect: 'preserve',
ignoresSessionLease: true,
...REQUEST_EXECUTION_EXEMPT,
},
timeoutPolicy: DEFAULT_TIMEOUT_POLICY,
Expand Down
5 changes: 5 additions & 0 deletions packages/command-registry/src/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -190,6 +191,7 @@ export const RAW_COMMAND_DESCRIPTORS = [
daemon: {
route: 'lease',
refFrameEffect: 'preserve',
ignoresSessionLease: true,
...ADMISSION_AND_LOCK_EXEMPT,
appLease: 'allowed',
},
Expand All @@ -208,6 +210,7 @@ export const RAW_COMMAND_DESCRIPTORS = [
daemon: {
route: 'lease',
refFrameEffect: 'preserve',
ignoresSessionLease: true,
...ADMISSION_AND_LOCK_EXEMPT,
appLease: 'allowed',
},
Expand Down Expand Up @@ -250,6 +253,7 @@ export const RAW_COMMAND_DESCRIPTORS = [
daemon: {
route: 'session',
refFrameEffect: 'preserve',
ignoresSessionLease: true,
sessionKind: 'inventory',
...REQUEST_EXECUTION_EXEMPT,
},
Expand Down Expand Up @@ -290,6 +294,7 @@ export const RAW_COMMAND_DESCRIPTORS = [
daemon: {
route: 'session',
refFrameEffect: 'preserve',
ignoresSessionLease: true,
sessionKind: 'inventory',
lockPolicySelectorOverride: true,
...REQUEST_EXECUTION_EXEMPT,
Expand Down
33 changes: 33 additions & 0 deletions packages/contracts/src/__tests__/lease-scope-rpc.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { expect, test } from 'vitest';
import { buildDaemonRpcRequest } from '../lease-scope.ts';

function leaseRpcParams(
flags: { session?: string },
meta: { sessionExplicit?: boolean },
): Record<string, unknown> {
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<string, unknown>;
}

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);
});
30 changes: 24 additions & 6 deletions packages/contracts/src/lease-scope.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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 <name>, 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'),
Expand Down Expand Up @@ -598,9 +611,14 @@ function buildLeaseRpcParams(
command: LeaseRpcCommand,
options: { includeTokenParam: boolean },
): Record<string, unknown> {
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
Comment thread
airowe marked this conversation as resolved.
Resolved
Comment thread
airowe marked this conversation as resolved.
Resolved
? {}
: { sessionExplicit: namesSessionExplicitly(req) }),
};
}
18 changes: 18 additions & 0 deletions packages/daemon/src/core/__tests__/daemon-command-registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 [
Expand Down
22 changes: 22 additions & 0 deletions packages/daemon/src/core/__tests__/execution-plan.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]);
});
4 changes: 4 additions & 0 deletions packages/daemon/src/core/daemon-command-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
}
Expand Down
6 changes: 6 additions & 0 deletions packages/daemon/src/core/daemon-request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,12 @@ type DaemonRequestInternal = ReplayDispatchOptions & {
*/
retainDeviceExecutionLock?: (deviceId: string) => Promise<void>;
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
Expand Down
4 changes: 2 additions & 2 deletions packages/daemon/src/core/explicit-session-resolution.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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(
Expand Down
29 changes: 29 additions & 0 deletions packages/daemon/src/core/request-session-intake.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
24 changes: 24 additions & 0 deletions packages/daemon/src/core/request-session-intake.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
meta?: { sessionExplicit?: boolean | undefined };
}): Pick<DaemonRequest, 'session' | 'internal'> {
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 } } : {}),
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -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' },
Expand All @@ -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);
Comment thread
airowe marked this conversation as resolved.
Resolved
} 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[] = [];
Expand Down
Loading
Loading