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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions contracts/fixtures/dispatch-disclosure.json
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,12 @@
"trigger": "get through the request router: the router, not only the interaction route, discloses no for a command the registry declares observes-app",
"dispatched": "no"
},
{
"id": "daemon.route.session-or-selector-refusal",
"producer": "daemon",
"trigger": "a device-bound command with no active session and no device selector: requireSessionOrExplicitSelector refuses before any target resolves, carrying details.reason session_or_device_selector_required",
"dispatched": "no"
},
{
"id": "daemon.route.find-type-refused-after-focus",
"producer": "daemon",
Expand Down
61 changes: 61 additions & 0 deletions packages/kernel/src/errors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,76 @@ import assert from 'node:assert/strict';
import { test } from 'vitest';
import {
AppError,
defaultHintForCode,
discloseDispatch,
discloseDispatchAfterSteps,
normalizeError,
PRE_DISPATCH_REFUSAL_REASONS,
sessionAppRequiredDetails,
sessionOrDeviceSelectorRequiredDetails,
throwDaemonError,
readElementMatchCandidateRefs,
readErrorCandidateViews,
summarizeCommandAttemptFailures,
} from './errors.ts';

test('the pre-dispatch refusal reasons are the values consumers branch on', () => {
assert.deepEqual(PRE_DISPATCH_REFUSAL_REASONS, {
sessionAppRequired: 'session_app_required',
sessionOrDeviceSelectorRequired: 'session_or_device_selector_required',
});
});

for (const [label, details, code, message] of [
[
'sessionAppRequired',
sessionAppRequiredDetails(),
'INVALID_ARGS',
'permission setting requires an active app in session',
],
[
'sessionOrDeviceSelectorRequired',
sessionOrDeviceSelectorRequiredDetails(),
'INVALID_ARGS',
'clipboard requires an active session or an explicit device selector (e.g. --platform ios).',
],
] as const) {
test(`${label} refusal carries its reason and dispatched:no through normalize and the wire`, () => {
const normalized = normalizeError(new AppError(code, message, details));
assert.equal(normalized.message, message);
assert.equal(normalized.details?.reason, details.reason);
assert.equal(normalized.details?.dispatched, 'no');
assert.throws(
() => throwDaemonError(normalized),
(error: unknown) =>
error instanceof AppError &&
error.details?.reason === details.reason &&
error.details?.dispatched === 'no',
);
});

test(`${label} reason survives a second normalization of the wire shape`, () => {
const once = normalizeError(new AppError(code, message, details));
const twice = normalizeError(new AppError(once.code, once.message, once.details));
assert.deepEqual(twice.details, once.details);
});
}

test('a published refusal keeps the INVALID_ARGS default hint — the reason carries the extra fact', () => {
for (const details of [sessionAppRequiredDetails(), sessionOrDeviceSelectorRequiredDetails()]) {
const normalized = normalizeError(new AppError('INVALID_ARGS', 'refused', details));
assert.equal(normalized.hint, defaultHintForCode('INVALID_ARGS'));
}
});

test('a sibling INVALID_ARGS refusal without a reason cannot activate a reason-driven consumer', () => {
const unrelated = normalizeError(
new AppError('INVALID_ARGS', 'clipboard requires a subcommand: read or write'),
);
assert.equal(unrelated.details?.reason, undefined);
assert.equal('dispatched' in (unrelated.details ?? {}), false);
});

test('normalizeError retains redacted nested command errors through repeated normalization', () => {
const stderr = `Launch request denied\n${'Context line\n'.repeat(50)}Underlying reason: token=private-value device locked`;
const expected = stderr.replace('private-value', '[REDACTED]');
Expand Down
55 changes: 55 additions & 0 deletions packages/kernel/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,61 @@ export function throwDaemonError(error: DaemonError): never {
);
}

/**
* The `details.reason` values a pre-dispatch refusal answers with when the caller must know *why* a
* request was stopped rather than read the prose: what a caller does to recover is the reason's
* meaning, and the reason strings are wire vocabulary (documented in
* `website/docs/docs/commands.md`). This map is the kernel-owned subset whose refusals always
* state `dispatched: 'no'` too — a refusal that provably reached no device is exactly what makes a
* retry safe. Other producers own their own reason strings beside their own disclosures.
*/
export const PRE_DISPATCH_REFUSAL_REASONS = {
/**
* A per-app setting was asked for while the request carries no app — the session binds none and,
* for a setting that takes an app id, none was named for this request. Recovery: `open` the app
* (or name the app id on the request), then retry. The daemon's `clear-app-state` pre-check and
* the Apple, Android, and HarmonyOS owners all answer with this one reason for every per-app
* setting, because the recovery is the same whichever setting asked.
*/
sessionAppRequired: 'session_app_required',
/**
* A device-bound command was asked for with neither an active session nor an explicit device
* selector, so there was no target to route to. Recovery: `open` a session or pass `--platform`
* or another device selector, then retry. One reason covers every command that asks the daemon to
* find a device for it; the command it asks for stays in the message.
*/
sessionOrDeviceSelectorRequired: 'session_or_device_selector_required',
} as const;

export type PreDispatchRefusalReason =
(typeof PRE_DISPATCH_REFUSAL_REASONS)[keyof typeof PRE_DISPATCH_REFUSAL_REASONS];

/** The details of a refusal that stopped a request before anything could reach a device. */
export type RefusalDetails<TReason extends PreDispatchRefusalReason> = AppErrorDetails & {
reason: TReason;
dispatched: 'no';
};

/** The details of a per-app setting refused for want of an app. */
export function sessionAppRequiredDetails(): RefusalDetails<
(typeof PRE_DISPATCH_REFUSAL_REASONS)['sessionAppRequired']
> {
return {
reason: PRE_DISPATCH_REFUSAL_REASONS.sessionAppRequired,
dispatched: 'no',
};
}

/** The details of a command refused for want of a target to route to. */
export function sessionOrDeviceSelectorRequiredDetails(): RefusalDetails<
(typeof PRE_DISPATCH_REFUSAL_REASONS)['sessionOrDeviceSelectorRequired']
> {
return {
reason: PRE_DISPATCH_REFUSAL_REASONS.sessionOrDeviceSelectorRequired,
dispatched: 'no',
};
}

/**
* `details.reason` of a request its requester abandoned — an explicit cancel or
* a client disconnect. One definition, so every layer that must let a
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { androidRevokedPermissionWarning } from '../settings-permission.ts';
import { ANDROID_EMULATOR } from './test-utils/device-fixtures.ts';
import { assertRejectsAppError } from './test-utils/app-error.ts';
import { withFakeAdb } from './test-utils/fake-adb.ts';
import { PRE_DISPATCH_REFUSAL_REASONS } from '@agent-device/kernel/errors';

// #1796. Two invariants decide every case here:
// * `pm` defaults grant/revoke and the permission-flag operations to UserHandle.USER_SYSTEM,
Expand Down Expand Up @@ -314,13 +315,18 @@ test.each([
);
});

test('setAndroidSetting permission requires an app in session', async () => {
test('setAndroidSetting permission requires an app in session with the published reason', async () => {
await assertRejectsAppError(
() =>
setAndroidSetting(ANDROID_EMULATOR, 'permission', 'deny', undefined, {
permissionTarget: 'camera',
}),
{ code: 'INVALID_ARGS', message: /requires an active app in session/ },
{
code: 'INVALID_ARGS',
message: /requires an active app in session/,
reason: PRE_DISPATCH_REFUSAL_REASONS.sessionAppRequired,
dispatched: 'no',
},
);
});

Expand Down
14 changes: 14 additions & 0 deletions packages/platform-android/src/__tests__/settings.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { test } from 'vitest';
import assert from 'node:assert/strict';
import { TEXT_SIZE_CATEGORIES } from '@agent-device/contracts/settings';
import { PRE_DISPATCH_REFUSAL_REASONS } from '@agent-device/kernel/errors';
import { readAndroidSetting, setAndroidSetting } from '../settings.ts';
import { ANDROID_EMULATOR } from './test-utils/device-fixtures.ts';
import { assertRejectsAppError } from './test-utils/app-error.ts';
Expand Down Expand Up @@ -63,6 +64,19 @@ test('setAndroidSetting clear-app-state force stops and clears package data', as
);
});

test('setAndroidSetting clear-app-state refuses an appless session with the published reason', async () => {
await withFakeAdb(
(args) => ({ stderr: `unexpected args: ${args.join(' ')}`, exitCode: 1 }),
async ({ device }) => {
await assertRejectsAppError(() => setAndroidSetting(device, 'clear-app-state', 'clear'), {
code: 'INVALID_ARGS',
reason: PRE_DISPATCH_REFUSAL_REASONS.sessionAppRequired,
dispatched: 'no',
});
},
);
});

test('setAndroidSetting fingerprint retries emulator command when shell cmd fingerprint fails', async () => {
await withFakeAdb(
(args) => {
Expand Down
29 changes: 24 additions & 5 deletions packages/platform-android/src/__tests__/test-utils/app-error.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,29 @@
import assert from 'node:assert/strict';
import { AppError, normalizeError } from '@agent-device/kernel/errors';

type ExpectedAppError = { code: string; message?: RegExp; hint?: string | RegExp };
type ExpectedAppError = {
code: string;
message?: RegExp;
hint?: string | RegExp;
/** The machine-readable `details.reason` the refusal must carry; key behavior on this, not on `message`. */
reason?: string;
/** The `details.dispatched` disclosure the producer must have proven. */
dispatched?: 'no' | 'unknown';
};

/** Keys the assertion on the machine-readable refusal facts rather than on `message` prose. */
function assertExpectedErrorDetails(error: AppError, expected: ExpectedAppError): void {
if (expected.reason !== undefined) {
assert.equal(
error.details?.reason,
expected.reason,
`expected details.reason ${expected.reason}, got ${JSON.stringify(error.details)}`,
);
}
if (expected.dispatched !== undefined) {
assert.equal(error.details?.dispatched, expected.dispatched);
}
}

function assertAppError(error: unknown, expected: ExpectedAppError): true {
assert.ok(
Expand All @@ -10,6 +32,7 @@ function assertAppError(error: unknown, expected: ExpectedAppError): true {
);
assert.equal(error.code, expected.code);
if (expected.message) assert.match(error.message, expected.message);
assertExpectedErrorDetails(error, expected);
if (expected.hint !== undefined) {
const { hint } = normalizeError(error);
assert.ok(typeof hint === 'string', `expected a hint on ${error.code}, got ${String(hint)}`);
Expand All @@ -25,7 +48,3 @@ export async function assertRejectsAppError(
): Promise<void> {
await assert.rejects(run, (error: unknown) => assertAppError(error, expected));
}

export function assertThrowsAppError(fn: () => unknown, expected: ExpectedAppError): void {
assert.throws(fn, (error: unknown) => assertAppError(error, expected));
}
8 changes: 7 additions & 1 deletion packages/platform-android/src/settings.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {
AppError,
sessionAppRequiredDetails,
summarizeCommandAttemptFailures,
type CommandAttemptFailure,
} from '@agent-device/kernel/errors';
Expand Down Expand Up @@ -124,6 +125,7 @@ export async function setAndroidSetting(
throw new AppError(
'INVALID_ARGS',
'settings clear-app-state requires an app id or an active app session.',
sessionAppRequiredDetails(),
);
}
const resolved = await resolveAndroidApp(device, appPackage);
Expand Down Expand Up @@ -162,7 +164,11 @@ export async function setAndroidSetting(
}
case 'permission': {
if (!appPackage) {
throw new AppError('INVALID_ARGS', 'permission setting requires an active app in session');
throw new AppError(
'INVALID_ARGS',
'permission setting requires an active app in session',
sessionAppRequiredDetails(),
);
}
return await setAndroidPermission(device, appPackage, state, options);
}
Expand Down
23 changes: 19 additions & 4 deletions packages/platform-apple/src/__tests__/app-error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,34 @@ type ExpectedAppError = {
normalizedMessage?: RegExp;
/** `null` asserts the failure attached no hint of its own, so normalization falls back to the code default. */
hint?: string | RegExp | null;
/** The machine-readable `details.reason` the refusal must carry; key behavior on this, not on `message`. */
reason?: string;
/** The `details.dispatched` disclosure the producer must have proven. */
dispatched?: 'no' | 'unknown';
};

/** Keys the assertion on the machine-readable refusal facts rather than on `message` prose. */
function assertExpectedErrorDetails(error: AppError, expected: ExpectedAppError): void {
if (expected.reason !== undefined) {
assert.equal(
error.details?.reason,
expected.reason,
`expected details.reason ${expected.reason}, got ${JSON.stringify(error.details)}`,
);
}
if (expected.dispatched !== undefined) {
assert.equal(error.details?.dispatched, expected.dispatched);
}
}

function assertAppError(error: unknown, expected: ExpectedAppError): true {
assert.ok(
error instanceof AppError,
`expected AppError, got ${error?.constructor?.name ?? typeof error}: ${String(error)}`,
);
assert.equal(error.code, expected.code);
if (expected.message) assert.match(error.message, expected.message);
assertExpectedErrorDetails(error, expected);
if (expected.normalizedMessage) {
assert.match(normalizeError(error).message, expected.normalizedMessage);
}
Expand All @@ -37,7 +56,3 @@ export async function assertRejectsAppError(
): Promise<void> {
await assert.rejects(run, (error: unknown) => assertAppError(error, expected));
}

export function assertThrowsAppError(fn: () => unknown, expected: ExpectedAppError): void {
assert.throws(fn, (error: unknown) => assertAppError(error, expected));
}
57 changes: 56 additions & 1 deletion packages/platform-apple/src/core/__tests__/app-settings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ const simulatorActual = await vi.importActual<typeof import('../simulator.ts')>(
import { setIosSetting } from '../app-settings.ts';
import { withMockedMacOsHelper } from './macos-helper-test-utils.ts';
import { ensureBootedSimulator } from '../simulator.ts';
import { AppError } from '@agent-device/kernel/errors';
import { AppError, PRE_DISPATCH_REFUSAL_REASONS } from '@agent-device/kernel/errors';
import { runCmd } from '@agent-device/host-kit/command';
import { retryWithPolicy } from '@agent-device/host-kit/retry';
import { assertRejectsAppError } from '../../__tests__/app-error.ts';
Expand Down Expand Up @@ -315,6 +315,61 @@ test('setIosSetting location set sends simulator latitude and longitude', async
);
});

test('setIosSetting permission requires an app in session with the published reason', async () => {
await withFakeAppleTool(
(args) => {
if (isSimctlListDevices(args)) return BOOTED_SIM_LIST_JSON;
return unexpectedArgs(args);
},
async () => {
await assertRejectsAppError(
() =>
setIosSetting(IOS_TEST_SIMULATOR, 'permission', 'grant', undefined, {
permissionTarget: 'camera',
}),
{
code: 'INVALID_ARGS',
reason: PRE_DISPATCH_REFUSAL_REASONS.sessionAppRequired,
dispatched: 'no',
},
);
},
);
});

test('setIosSetting location refuses an appless session with the published reason', async () => {
mockEnsureBootedSimulator.mockResolvedValue(undefined);

await withFakeAppleTool(
(args) => unexpectedArgs(args),
async () => {
await assertRejectsAppError(() => setIosSetting(IOS_TEST_SIMULATOR, 'location', 'on'), {
code: 'INVALID_ARGS',
reason: PRE_DISPATCH_REFUSAL_REASONS.sessionAppRequired,
dispatched: 'no',
});
},
);
});

test('setIosSetting clear-app-state refuses an appless session with the published reason', async () => {
mockEnsureBootedSimulator.mockResolvedValue(undefined);

await withFakeAppleTool(
(args) => unexpectedArgs(args),
async () => {
await assertRejectsAppError(
() => setIosSetting(IOS_TEST_SIMULATOR, 'clear-app-state', 'clear'),
{
code: 'INVALID_ARGS',
reason: PRE_DISPATCH_REFUSAL_REASONS.sessionAppRequired,
dispatched: 'no',
},
);
},
);
});

test('setIosSetting appearance toggle flips current simulator appearance', async () => {
await withFakeAppleTool(
(args) => {
Expand Down
Loading
Loading