Files
software-mansion__argent/packages/tool-server/test/http-external-revocation.test.ts
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

124 lines
3.9 KiB
TypeScript

/**
* The revocation gate at the HTTP edge, in the case where revocation does not
* work.
*
* A provider narrowing or withdrawing a grant only bites because the dispatch
* edge drops the warm handles first. The capability gate itself runs in the
* factory and a cached service never re-enters one. So a sweep that cannot
* finish is not a tidy-up that failed, it is the enforcement point failing and
* the request behind it has to stop rather than reach a service the provider
* has taken back.
*/
import { describe, it, expect, beforeEach, vi } from "vitest";
import request from "supertest";
import { Registry } from "@argent/registry";
import { z } from "zod";
const enforceMock = vi.fn();
vi.mock("../src/utils/external-devices", async (importOriginal) => {
const actual = await importOriginal<typeof import("../src/utils/external-devices")>();
return {
...actual,
enforceExternalDeviceGrant: (...args: unknown[]) => enforceMock(...args),
};
});
import { createHttpApp } from "../src/http";
const DEVICE_ID = "ext:acme-3f2a9c:1A2B3C4D-5E6F-7081-92A3-B4C5D6E7F809";
/**
* A tool that reports whether it ran, so "did the request get through" is
* observable.
*/
function registryWithProbe(ran: { value: boolean }): Registry {
const registry = new Registry();
registry.registerTool({
execute: async () => {
ran.value = true;
return { ok: true };
},
id: "probe",
services: () => ({}),
zodSchema: z.object({ device_id: z.string() }),
});
return registry;
}
describe("a revocation sweep that cannot finish stops the request", () => {
beforeEach(() => {
enforceMock.mockReset();
});
it("answers 500 and does not invoke the tool", async () => {
enforceMock.mockRejectedValue(new Error("SimulatorServer:ext:acme-3f2a9c:… (teardown wedged)"));
const ran = { value: false };
const { app } = createHttpApp(registryWithProbe(ran));
const res = await request(app).post("/tools/probe").send({ device_id: DEVICE_ID });
expect(res.status).toBe(500);
expect(res.body.error).toMatch(/teardown wedged/);
expect(ran.value).toBe(false);
});
it("records the failure under its own signal", async () => {
enforceMock.mockRejectedValue(new Error("nope"));
const recordFailure = vi.fn();
const { app } = createHttpApp(registryWithProbe({ value: false }), { recordFailure });
await request(app).post("/tools/probe").send({ device_id: DEVICE_ID });
expect(recordFailure).toHaveBeenCalledWith(
"probe",
expect.objectContaining({ device_provider: "acme" }),
{
error_code: "EXTERNAL_DEVICE_REVOCATION_INCOMPLETE",
failure_stage: "external_device_revocation",
failure_area: "http",
error_kind: "unknown",
},
expect.any(Number)
);
});
/** The control. A sweep that completes hands the call on as it always did. */
it("dispatches when the sweep completes", async () => {
enforceMock.mockResolvedValue({ reason: "its provider changed what it grants", stale: true });
const ran = { value: false };
const { app } = createHttpApp(registryWithProbe(ran));
const res = await request(app).post("/tools/probe").send({ device_id: DEVICE_ID });
expect(res.status).toBe(200);
expect(ran.value).toBe(true);
});
/** Nothing stale means nothing was dropped, so the gate stays quiet. */
it("says nothing when the grant is unchanged", async () => {
enforceMock.mockResolvedValue({ stale: false });
const written = vi.spyOn(process.stderr, "write").mockReturnValue(true);
try {
const ran = { value: false };
const { app } = createHttpApp(registryWithProbe(ran));
const res = await request(app).post("/tools/probe").send({ device_id: DEVICE_ID });
expect(res.status).toBe(200);
expect(ran.value).toBe(true);
expect(written.mock.calls.flat().join("")).not.toMatch(/dropped cached services/);
} finally {
written.mockRestore();
}
});
});