Skip to content
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ See the [Node.js API](https://oss.callstack.com/agent-device/docs/client-api), t

## What agents can do

- **Inspect app state** through accessibility snapshots, refs, selectors, and React Native component trees.
- **Inspect app state** through accessibility snapshots, refs, and selectors, and profile React Native renders to find what makes an interaction slow.
- **Act on visible UI** by tapping or pressing elements, filling fields, scrolling, making gestures, waiting, asserting state, and handling alerts.
- **Diagnose failures** with screenshots, video, logs, traces, network data, performance samples, crash details, and React profiles.
- **Repeat workflows** by saving working steps as `.ad` scripts for local use or CI. Export strict Maestro YAML when needed.
Expand Down
4 changes: 2 additions & 2 deletions docs/adr/0029-daemon-policy.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,13 +47,13 @@ Accepted (2026-09-29).
- public commands, by their own name;
- internal commands with platform execution, by the public command their
`catalog.servesPublicCommand` names (`install_source` as `install-from-source`), or else by
their own name (`runtime`, which `react-devtools` and Maestro flows send);
their own name (`runtime`, which Maestro flows send);
Comment thread
thymikee marked this conversation as resolved.
- internal commands with no platform execution (leases, `human_control`, session bookkeeping) are
protocol plumbing that no rule decides;
- any other name is decided by the rules, so an allow list fails closed for it.

Names are checked against that vocabulary at load; a local-cli name such as `react-devtools` is
refused with a pointer to the daemon command it sends. With `allow`, commands added by a later
refused, because it runs in the client. With `allow`, commands added by a later
upgrade are denied by default.
- `capabilities.deny` — operations denied whichever command reaches them. `device-shutdown` is the
only capability today.
Expand Down
23 changes: 1 addition & 22 deletions packages/command-registry/src/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,19 +150,6 @@ export type DescriptorSessionRouteCommandName =
: never
: never;

/**
* The literal union of every command the daemon routes. A command that never reaches a
* daemon route cannot be the target of a request, so this is the type a caller building
* one must name — an unregistered or CLI-only target is a compile error rather than a
* string the receiver rejects at runtime.
*/
export type DescriptorDaemonRouteCommandName =
Extract<(typeof commandDescriptors)[number], { daemon: object }> extends infer Descriptor
? Descriptor extends { name: infer Name extends string }
? Name
: never
: never;

// ---------------------------------------------------------------------------
// Daemon request-policy trait bundles — copied VERBATIM from
// packages/daemon/src/core/daemon-command-registry.ts (DAEMON_COMMAND_DESCRIPTORS).
Expand Down Expand Up @@ -1814,13 +1801,6 @@ export const RAW_COMMAND_DESCRIPTORS = [
timeoutPolicy: DEFAULT_TIMEOUT_POLICY,
batchable: false,
mcpExposed: false,
// Wave 6 residue: `react-devtools start` on a Limrun Android instance dispatches internal
// `runtime port-reverse` through the CLI-injected dispatch seam. `runtime` is itself fully
// migrated (`device-runtime`, R31), so the dispatched execution is accounted for under its
// own descriptor rather than hidden behind this one — this route owns no unmigrated platform
// execution of its own. The CLI-route dominance gate
// (`__tests__/platform-execution-cli-route.test.ts`) still rejects a `none` route whose
// CLI-injected dispatch target is still `legacy`.
platformExecution: NO_PLATFORM_EXECUTION,
},
{
Expand Down Expand Up @@ -1921,8 +1901,7 @@ type Equal<A, B> = [A] extends [B] ? ([B] extends [A] ? true : false) : false;
* Compile-time literalness of {@link Command}. The `as const` on {@link RAW_COMMAND_DESCRIPTORS}
* keeps each descriptor's literal `name` through the composition and the `.map` above; an array
* that lost it would widen this union to `string`, and nothing downstream would say so:
* `COMMAND_DEFAULTS` below and `Record<DescriptorCliCommandName, …>` in
* `src/cli/injected-daemon-dispatch.ts` stop rejecting an unknown key, and the `command: Command`
* `COMMAND_DEFAULTS` below stops rejecting an unknown key, and the `command: Command`
* parameters in `src/cli/command-explain.ts` and `owner-files.ts` would stop excluding a name no
* descriptor declares. That silence is why this is a type guard and not a value comparison.
*/
Expand Down
2 changes: 1 addition & 1 deletion skills/android-emulator/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,6 @@ Only when the task is specialized or a command shape is unclear, read the releva
agent-device help validate # engineering validation and build freshness
agent-device help debugging # screenshots, logs, traces, video, and failures
agent-device help react-native # React Native and Expo runtime guidance
agent-device help react-devtools # component tree, props/state/hooks, and renders
agent-device help react-devtools # React Native performance profiling
agent-device help scripting # durable replay and CI workflows
```
2 changes: 1 addition & 1 deletion skills/ios-simulator/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,6 @@ Only when the task is specialized or a command shape is unclear, read the releva
agent-device help validate # engineering validation and build freshness
agent-device help debugging # screenshots, logs, traces, video, and failures
agent-device help react-native # React Native and Expo runtime guidance
agent-device help react-devtools # component tree, props/state/hooks, and renders
agent-device help react-devtools # React Native performance profiling
agent-device help scripting # durable replay and CI workflows
```
4 changes: 2 additions & 2 deletions src/__tests__/cli-help.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,8 @@ test('help react-devtools prints agent workflow topic and skips daemon dispatch'
assert.equal(result.code, 0);
assert.equal(result.calls.length, 0);
assert.match(result.stdout, /^agent-device \S+ — react-devtools/);
assert.match(result.stdout, /React Native performance\/profiling/);
assert.match(result.stdout, /agent-device react-devtools status/);
assert.match(result.stdout, /React Native performance profiling/);
assert.match(result.stdout, /agent-device react-devtools profile start/);
});

test('help workflow prints the compact workflow card with a version header and skips daemon dispatch', async () => {
Expand Down
99 changes: 4 additions & 95 deletions src/__tests__/cli-react-devtools-session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,42 +15,12 @@ import {
hashRemoteConfigFile,
writeRemoteConnectionState,
} from '../remote/remote-connection-state.ts';
import type {
DaemonRequest,
DaemonResponse,
sendToDaemon as SendToDaemon,
} from '../daemon-client/daemon-client.ts';
import type { DaemonResponse } from '../daemon-client/daemon-client.ts';

afterEach(() => {
vi.clearAllMocks();
});

function createLimrunConnectionFixture(): { tempRoot: string; stateDir: string } {
const tempRoot = mkdtempForTestSync('agent-device-react-devtools-limrun-');
const stateDir = path.join(tempRoot, 'state');
const remoteConfigPath = path.join(tempRoot, 'limrun.json');
fs.writeFileSync(remoteConfigPath, JSON.stringify({ platform: 'android' }));
writeRemoteConnectionState({
stateDir,
state: {
version: 1,
session: 'limrun-android',
remoteConfigPath,
remoteConfigHash: hashRemoteConfigFile(remoteConfigPath),
daemon: {},
tenant: 'limrun',
runId: 'run-1',
leaseId: 'lease-1',
leaseBackend: 'android-instance',
leaseProvider: 'limrun',
platform: 'android',
connectedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
});
return { tempRoot, stateDir };
}

test('react-devtools uses active remote connection session after defaults are merged', async () => {
const tempRoot = mkdtempForTestSync('agent-device-react-devtools-session-');
const stateDir = path.join(tempRoot, 'state');
Expand Down Expand Up @@ -92,7 +62,9 @@ test('react-devtools uses active remote connection session after defaults are me
const sendToDaemon = async (): Promise<DaemonResponse> => ({ ok: true, data: {} });

try {
await runCli(['react-devtools', 'status', '--state-dir', stateDir], { sendToDaemon });
await runCli(['react-devtools', 'profile', 'status', '--state-dir', stateDir], {
sendToDaemon,
});
} finally {
restoreEnv();
process.exit = originalExit;
Expand All @@ -103,66 +75,3 @@ test('react-devtools uses active remote connection session after defaults are me
assert.equal(vi.mocked(runReactDevtoolsCommand).mock.calls.length, 1);
assert.equal(vi.mocked(runReactDevtoolsCommand).mock.calls[0]?.[1]?.session, 'adc-android');
});

test('react-devtools starts Limrun port reverse through the daemon', async () => {
const { tempRoot, stateDir } = createLimrunConnectionFixture();
const originalExit = process.exit;
let exitCode: number | undefined;
const restoreEnv = installIsolatedCliTestEnv();
(process as any).exit = ((code?: number) => {
exitCode = code ?? 0;
}) as typeof process.exit;
const requests: Array<Omit<DaemonRequest, 'token'>> = [];
const transportOptions: Array<Parameters<typeof SendToDaemon>[1]> = [];
const sendToDaemon = async (
request: Omit<DaemonRequest, 'token'>,
options?: Parameters<typeof SendToDaemon>[1],
): Promise<DaemonResponse> => {
requests.push(request);
transportOptions.push(options);
return { ok: true, data: {} };
};
vi.mocked(runReactDevtoolsCommand).mockImplementationOnce(async (_args, options) => {
assert.ok(options?.configureDirectPortReverse);
await options.configureDirectPortReverse();
return 0;
});

try {
await runCli(
[
'react-devtools',
'start',
'--state-dir',
stateDir,
'--daemon-auth-token',
'synthetic-direct-token',
],
{ sendToDaemon },
);
} finally {
restoreEnv();
process.exit = originalExit;
fs.rmSync(tempRoot, { recursive: true, force: true });
}

assert.equal(exitCode, 0);
assert.equal(requests.length, 1);
const request = requests[0];
assert.ok(request);
assert.equal(request.command, 'runtime');
assert.deepEqual(request.positionals, ['port-reverse']);
assert.equal(request.session, 'limrun-android');
const flags = request.flags;
assert.ok(flags);
assert.equal(flags.platform, 'android');
assert.equal(flags.leaseId, 'lease-1');
assert.equal(flags.leaseProvider, 'limrun');
assert.equal(flags.devicePort, 8097);
assert.equal(flags.hostPort, 8097);
assert.equal(flags.portReverseName, 'react-devtools');
assert.equal(Object.hasOwn(flags, 'daemonAuthToken'), false);
assert.deepEqual(transportOptions, [{ authToken: 'synthetic-direct-token' }]);
const commandOptions = vi.mocked(runReactDevtoolsCommand).mock.calls[0]?.[1];
assert.equal(Object.hasOwn(commandOptions?.flags ?? {}, 'daemonAuthToken'), false);
});
Loading
Loading