Files
Mateusz Aliyev 6e9e45c9f0 feat: attach to devices offered by external providers (#735)
This pull request lets Argent drive a simulator or emulator another
process is already running, attaching to its simulator-server rather
than spawning a second one.

A provider writes a JSON descriptor to `~/.argent/providers/` listing
the devices it offers and the mechanisms Argent may use on each. They
appear in `list-devices` with an `ext:` id and work with the existing
tools. The file is re-read on every call, so a withdrawal or a narrowed
grant applies immediately.

`ios.additionalDeviceSets` (#600) already makes such a simulator
reachable by UDID. What changes is ownership. `boot-device` and
`stop-simulator-server` refuse these devices, anything the provider did
not grant is refused with a message naming it and Argent uses only the
endpoints its own simulator-server build serves. A grant binds to the
device rather than to one of its names, so the real udid or serial is
gated exactly like the `ext:` id. Android emulators are covered too and
a device visible to both a provider and `adb`/`simctl` is listed once.

The contract ships as `schemas/device-provider-v1.json`, validated by
`argent providers check`, and is documented in
`docs/reference/device-providers.mdx`. Includes unit tests and an e2e
phase where the harness acts as its own provider.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Added support for discovering and connecting to externally managed
devices.
* Added `argent providers` commands for listing, validating, publishing,
withdrawing, and pruning providers.
* Added provider-aware simulator, debugger, profiler, native tools, and
device listings.
* Added capability controls, endpoint validation, revocation handling,
and provider-specific diagnostics.
  * Added automatic CLI discovery for provider integrations.
* **Bug Fixes**
  * Improved service recovery and paused-runtime error reporting.
  * Improved simulator keyboard and paste command reliability.
* **Documentation**
* Documented provider descriptors, capabilities, lifecycle rules, and
CLI usage.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-09-04 22:02:57 +02:00

232 lines
8.8 KiB
TypeScript

import type { ServiceRef, ToolDefinition } from "@argent/registry";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { debuggerComponentTreeTool } from "../src/tools/debugger/debugger-component-tree";
import { debuggerConnectTool } from "../src/tools/debugger/debugger-connect";
import { debuggerInspectElementTool } from "../src/tools/debugger/debugger-inspect-element";
import { debuggerReloadMetroTool } from "../src/tools/debugger/debugger-reload-metro";
import { networkLogsTool } from "../src/tools/network/network-logs";
import { networkRequestTool } from "../src/tools/network/network-request";
import { reactProfilerFiberTreeTool } from "../src/tools/profiler/react/react-profiler-fiber-tree";
import { reactProfilerRendersTool } from "../src/tools/profiler/react/react-profiler-renders";
import { forgetDeviceAlias, rememberDeviceAlias } from "../src/utils/debugger/device-alias";
import { metroPort, publishedMetroPort } from "../src/utils/debugger/metro-port";
import {
__resetExternalDeviceCacheForTesting,
__resetProviderWarningsForTesting,
makeExternalId,
} from "../src/utils/external-devices";
/**
* A provider running several projects gives each a free port, so an agent
* cannot guess it. The descriptor's `metroPort` supplies the default instead.
*
* `(port, device_id)` is a session key, naming the CDP service in its URN and
* the captured profile on disk. Two tools deriving it differently do not fail,
* they address different sessions. `debugger-connect` opens the app and
* `debugger-evaluate` finds nothing. Hence the invariant at the bottom.
*/
const ANDROID_SERIAL = "emulator-5554";
/** A serial no descriptor claims, the device argent booted for itself. */
const UNCLAIMED_SERIAL = "emulator-5556";
const PROVIDER_ID = "acme-3f2a9c";
const PROVIDER_METRO_PORT = 54321;
const DEVICE_ID = makeExternalId(PROVIDER_ID, ANDROID_SERIAL);
let temporaryDirectory: string;
/**
* Publish a descriptor whose device declares `metroPort` unless told not to.
*/
function publishDescriptor(options: { metroPort?: number } = {}): void {
const descriptorPath = path.join(temporaryDirectory, "acme.json");
fs.writeFileSync(
descriptorPath,
JSON.stringify({
devices: [
{
capabilities: ["adb", "js-debugger"],
kind: "emulator",
...(options.metroPort === undefined ? {} : { metroPort: options.metroPort }),
name: "Pixel 9",
nativeId: ANDROID_SERIAL,
platform: "android",
state: "device",
},
],
id: PROVIDER_ID,
name: "Acme IDE",
schemaVersion: 1,
})
);
process.env.ARGENT_DEVICE_PROVIDERS = descriptorPath;
}
beforeEach(() => {
temporaryDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "argent-metro-port-"));
delete process.env.ARGENT_DISABLE_DEVICE_PROVIDERS;
__resetExternalDeviceCacheForTesting();
__resetProviderWarningsForTesting();
publishDescriptor({ metroPort: PROVIDER_METRO_PORT });
});
afterEach(() => {
delete process.env.ARGENT_DEVICE_PROVIDERS;
fs.rmSync(temporaryDirectory, { force: true, recursive: true });
});
describe("metroPort", () => {
it("takes the port the device's provider publishes", () => {
expect(metroPort({ device_id: DEVICE_ID })).toBe(PROVIDER_METRO_PORT);
});
it("lets an explicit port win, so a second bundler stays addressable", () => {
expect(metroPort({ device_id: DEVICE_ID, port: 9000 })).toBe(9000);
});
it("falls back to 8081 when the provider publishes no port", () => {
publishDescriptor();
expect(metroPort({ device_id: DEVICE_ID })).toBe(8081);
});
it("falls back to 8081 for a device argent booted itself", () => {
expect(metroPort({ device_id: UNCLAIMED_SERIAL })).toBe(8081);
});
/**
* The grant and the published port bind to the device, not to one of its
* names. A caller naming the provider's emulator the way `adb devices` does
* has to reach the same Metro as one naming it `ext:...` or the debugger
* dials 8081 while the app is served from the provider's port.
*/
it("takes the provider's port for the raw serial it claims", () => {
expect(metroPort({ device_id: ANDROID_SERIAL })).toBe(PROVIDER_METRO_PORT);
});
it("tolerates a missing device id", () => {
expect(metroPort({})).toBe(8081);
});
/**
* `debugger-connect` hands back Metro's own per-connection handle and a
* caller is free to forward it. It resolves to the same service (that is what
* the alias map is for), so it has to resolve to the same port too.
*/
it("resolves the provider through a forwarded logicalDeviceId", () => {
rememberDeviceAlias("metro-logical-1", DEVICE_ID);
try {
expect(metroPort({ device_id: "metro-logical-1" })).toBe(PROVIDER_METRO_PORT);
} finally {
forgetDeviceAlias("metro-logical-1");
}
});
});
describe("publishedMetroPort", () => {
it("reports the provider's port when the call is using a different one", () => {
expect(publishedMetroPort(DEVICE_ID, 8081)).toBe(PROVIDER_METRO_PORT);
});
it("says nothing when the call already uses the published port", () => {
expect(publishedMetroPort(DEVICE_ID, PROVIDER_METRO_PORT)).toBeUndefined();
});
it("says nothing about a device with no provider", () => {
expect(publishedMetroPort(UNCLAIMED_SERIAL, 8081)).toBeUndefined();
});
it("reports the provider's port for the raw serial it claims", () => {
expect(publishedMetroPort(ANDROID_SERIAL, 8081)).toBe(PROVIDER_METRO_PORT);
});
});
/**
* These tools do not share a URN builder: three predate `debuggerServiceRef`
* and interpolate the pair themselves. So this drives each one's real
* `services()` rather than the helper.
*/
const URN_TOOLS: {
name: string;
params: Record<string, unknown>;
tool: ToolDefinition<any, any>;
}[] = [
{ name: "debugger-component-tree", params: {}, tool: debuggerComponentTreeTool },
{ name: "debugger-connect", params: {}, tool: debuggerConnectTool },
{ name: "debugger-inspect-element", params: { x: 1, y: 1 }, tool: debuggerInspectElementTool },
{ name: "debugger-reload-metro", params: {}, tool: debuggerReloadMetroTool },
{ name: "react-profiler-fiber-tree", params: {}, tool: reactProfilerFiberTreeTool },
{ name: "react-profiler-renders", params: {}, tool: reactProfilerRendersTool },
{ name: "view-network-logs", params: {}, tool: networkLogsTool },
{ name: "view-network-request-details", params: { requestId: "1" }, tool: networkRequestTool },
];
function urnsFor(tool: ToolDefinition<any, any>, params: Record<string, unknown>): string[] {
const parsed = tool.zodSchema!.parse({ device_id: DEVICE_ID, ...params });
return Object.values(tool.services(parsed) as Record<string, ServiceRef>).map((ref) =>
typeof ref === "string" ? ref : ref.urn
);
}
describe("service URNs for a provider's device", () => {
it.each(URN_TOOLS)("$name keys its session on the published port", ({ params, tool }) => {
const urns = urnsFor(tool, params);
expect(urns.length).toBeGreaterThan(0);
for (const urn of urns) {
expect(urn).toContain(`:${PROVIDER_METRO_PORT}:`);
expect(urn).not.toContain(":8081:");
}
});
it.each(URN_TOOLS)("$name still honours an explicit port", ({ params, tool }) => {
for (const urn of urnsFor(tool, { ...params, port: 9000 })) {
expect(urn).toContain(":9000:");
}
});
});
/**
* The type system covers value call sites, since `port` is optional and
* reading it raw is a compile error. It does not cover URN builders,
* `${params.port}` interpolates `undefined` happily and keys the session on
* that string. This is the backstop and for any tool added later that
* declares the pair.
*/
describe("no tool reads the raw port", () => {
const toolsRoot = path.join(__dirname, "..", "src", "tools");
function sourceFiles(directory: string): string[] {
return fs.readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
const entryPath = path.join(directory, entry.name);
if (entry.isDirectory()) return sourceFiles(entryPath);
return entry.isFile() && entry.name.endsWith(".ts") ? [entryPath] : [];
});
}
const metroSessionTools = sourceFiles(toolsRoot)
.map((file) => ({ file, source: fs.readFileSync(file, "utf8") }))
.filter(({ source }) => /^\s*port: /m.test(source) && /^\s*device_id: /m.test(source));
it("finds the tools that key a session on (port, device_id)", () => {
// A rename that silently empties the list would make every case below vacuous.
expect(metroSessionTools.length).toBeGreaterThanOrEqual(20);
});
it.each(metroSessionTools)("$file declares the shared port field", ({ source }) => {
expect(source).toContain("port: metroPortField,");
});
it.each(metroSessionTools)("$file resolves the port before using it", ({ source }) => {
expect(source).not.toMatch(/params\.port/);
});
});