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
5 changes: 5 additions & 0 deletions .fallowrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,11 @@
"file": "packages/provider-*/src/index.ts",
"exports": ["default"]
},
{
// The version 1 entry a provider package's `agentDevicePlugin.entry` names; hosts without the version 2 contract import the built file and read `default`.
"file": "packages/provider-*/src/plugin.ts",
"exports": ["default"]
},
{
// packages/platform-apple/src/lifecycle.ts loads this module on demand and reads the member off the memoized import() promise, which Fallow cannot follow.
"file": "packages/platform-apple/src/expo-dev-client-launch.ts",
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
## Unreleased

- Fix `ensureDaemon()`, the CLI and MCP killing a daemon they had just asked to leave. After the daemon answers the quiesce request with `draining`, the client now waits up to 60 s for it to exit by itself and clean up its registration, so plugin disposal and lease finalization finish (on Windows a signal ends the process outright, and on POSIX a drain longer than 3 s was killed). A daemon still running at the deadline that fails its liveness probes is force-retired; one that still answers is left running and the call fails with `details.reason: 'daemon-drain-timeout'`, with a hint naming `agent-device daemon stop --state-dir <path>`.
- `@agent-device/testmu` is now a version 2 provider plugin. `import testmu from '@agent-device/testmu'` gives a factory, `testmu({ credentials: { username, accessKey } })`, that reads those credentials instead of `LT_USERNAME` and `LT_ACCESS_KEY`; without options it reads the variables as before. The package still declares the version 1 plugin entry, so `agent-device plugins add @agent-device/testmu` keeps working on hosts that predate the version 2 contract.
- Provider plugins have a new contract. A package exports a factory at `./agent-device` that returns `{ apiVersion: 2, provider, credentials, connection?, setup(host) }`, typed by `AgentDevicePluginFactory` from `agent-device/plugins`. The store calls the factory with the package's `config.json` options, and a JavaScript host can call it directly. Version 1 plugins (`agentDevicePlugin.apiVersion: 1`) keep loading.
- Bundled providers and installed plugins now register through one registry, in that order, with a third slot for embedder plugins (#3757). The first plugin to name a provider id claims it; a later duplicate is recorded as failed, and `cloud` and `proxy` stay reserved.
- A plugin that fails to load, validate, or set up no longer stops the daemon. It is listed as `failed` with a typed `error.reason`, and the other providers still register. The new `agent-device daemon status [--json]` shows the inventory, which the daemon also publishes in `daemon.json`; `plugins list --json` works without a daemon and reports a running daemon's inventory under `daemon`.
Expand Down
12 changes: 12 additions & 0 deletions packages/provider-testmu/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,15 @@ See the [TestMu AI guide](https://oss.callstack.com/agent-device/docs/testmu) fo
To update the plugin, run `agent-device plugins update @agent-device/testmu`.

After you add or update the plugin, close your sessions and run `agent-device daemon stop` before reconnecting.

## Use it from JavaScript

The package default-exports a plugin factory. Pass credentials as options, or leave them out to read `LT_USERNAME` and `LT_ACCESS_KEY` from the environment:

```js
import testmu from '@agent-device/testmu';

const plugin = testmu({ credentials: { username: 'your-username', accessKey: 'your-access-key' } });
```

Credentials passed as options are used instead of the environment variables, and the daemon does not compare them with your shell.
9 changes: 8 additions & 1 deletion packages/provider-testmu/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@
"types": "./src/index.ts",
"default": "./src/index.ts"
},
"./agent-device": {
"types": "./src/index.ts",
"default": "./src/index.ts"
},
"./connection-verification": {
"types": "./src/testmu-connection-verification.ts",
"default": "./src/testmu-connection-verification.ts"
Expand Down Expand Up @@ -48,7 +52,10 @@
"publishConfig": {
"exports": {
".": {
"import": "./dist/plugin.mjs"
"import": "./dist/agent-device.mjs"
},
"./agent-device": {
"import": "./dist/agent-device.mjs"
}
},
"access": "public"
Expand Down
9 changes: 3 additions & 6 deletions packages/provider-testmu/src/connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import {
rejectRefusedProviderProfileFields,
requireResolvedProfilePlatform,
requireResolvedProfileValue,
type ProviderProfileFieldDeclaration,
} from '@agent-device/contracts/provider-profile-fields';
import {
requireConnectFlag,
Expand All @@ -16,19 +15,17 @@ import {
isTestMuAppReference,
requireTestMuCredentials,
} from './providers.ts';
import { TESTMU_PROFILE_FIELDS } from './profile-fields.ts';
import { verifyTestMuConnection } from './testmu-connection-verification.ts';
import { readTestMuDeviceFeatureFields, readTestMuDeviceType } from './testmu-device-features.ts';

const PROVIDER = 'testmu';
const SERVICE = 'TestMu AI';

export function createTestMuConnection(
host: ProviderPluginHost,
fields: ProviderProfileFieldDeclaration,
) {
export function createTestMuConnection(host: ProviderPluginHost) {
return {
resolve: ({ flags, cwd }: { flags: CliFlags; cwd: string }) => {
rejectRefusedProviderProfileFields(flags, fields);
rejectRefusedProviderProfileFields(flags, TESTMU_PROFILE_FIELDS);
requireTestMuCredentials(host.env, `connect ${PROVIDER}`);
const platform = requireConnectPlatform(flags, PROVIDER);
const app = connectAppReference(
Expand Down
137 changes: 137 additions & 0 deletions packages/provider-testmu/src/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import assert from 'node:assert/strict';
import { afterEach, test, vi } from 'vitest';
import type { ProviderPluginHost } from 'agent-device/plugins';
import { AppError } from '@agent-device/kernel/errors';
import testmu from './index.ts';
import { verifyTestMuConnection } from './testmu-connection-verification.ts';
import manifest from '../package.json' with { type: 'json' };

vi.mock('./testmu-connection-verification.ts', () => ({ verifyTestMuConnection: vi.fn() }));

const realFetch = globalThis.fetch;

afterEach(() => {
globalThis.fetch = realFetch;
vi.clearAllMocks();
});

function host(env: Record<string, string | undefined>): ProviderPluginHost {
return {
env,
options: {},
clientVersion: '0.0.0-test',
createError: (code, message, details) => new AppError(code, message, details),
};
}

function capturedAuthorization(): { read(): string | undefined } {
let authorization: string | undefined;
globalThis.fetch = async (_input, init) => {
authorization = new Headers(init?.headers).get('authorization') ?? undefined;
return new Response(JSON.stringify({ data: {} }));
};
return { read: () => authorization };
}

async function listArtifacts(plugin: ReturnType<typeof testmu>, env: Record<string, string>) {
const setup = await plugin.setup(host(env));
if (!('webDriver' in setup)) throw new Error('TestMu AI sets up a WebDriver descriptor');
await setup.webDriver.listArtifacts!({ provider: 'testmu', providerSessionId: 'SESSION1' });
}

const basic = (username: string, accessKey: string) =>
`Basic ${Buffer.from(`${username}:${accessKey}`).toString('base64')}`;

test('the plugin declares the LT variables only when it reads credentials from the environment', () => {
assert.deepEqual(testmu().credentials, { variables: ['LT_USERNAME', 'LT_ACCESS_KEY'] });
assert.deepEqual(testmu({ credentials: { username: 'u', accessKey: 'k' } }).credentials, {
variables: [],
});
});

test('a credentials object missing one key still declares the variable it reads from the environment', () => {
assert.deepEqual(testmu({ credentials: { username: 'u' } as never }).credentials, {
variables: ['LT_ACCESS_KEY'],
});
assert.deepEqual(testmu({ credentials: { accessKey: 'k' } as never }).credentials, {
variables: ['LT_USERNAME'],
});
});

test('credentials passed as options replace the environment, which stays the fallback', async () => {
const captured = capturedAuthorization();
const env = { LT_USERNAME: 'env-user', LT_ACCESS_KEY: 'env-key' };

await listArtifacts(testmu({ credentials: { username: 'opt-user', accessKey: 'opt-key' } }), env);
assert.equal(captured.read(), basic('opt-user', 'opt-key'));
await listArtifacts(testmu({ credentials: { username: 'opt-user', accessKey: 'opt-key' } }), {});
assert.equal(captured.read(), basic('opt-user', 'opt-key'));
await listArtifacts(testmu(), env);
assert.equal(captured.read(), basic('env-user', 'env-key'));
await assert.rejects(listArtifacts(testmu(), {}), { code: 'INVALID_ARGS' });
});

test('connect uses the option credentials without an environment and verifies with them', async () => {
vi.mocked(verifyTestMuConnection).mockResolvedValue({
provider: 'testmu',
service: 'TestMu AI',
verificationMessage: 'verified',
device: { status: 'verified', name: 'iPhone 16', platform: 'ios', osVersion: '18.0' },
app: { status: 'verified', reference: 'lt://APP1' },
});
const { connection } = testmu({ credentials: { username: 'opt-user', accessKey: 'opt-key' } });
const flags = {
json: false,
help: false,
version: false,
platform: 'ios',
device: 'iPhone 16',
providerOsVersion: '18.0',
providerApp: 'lt://APP1',
} as const;

const resolved = await connection!.resolve({
flags,
stateDir: '/state',
cwd: '/work',
env: {},
host: host({}),
});
assert.equal(resolved.profile.leaseProvider, 'testmu');
await connection!.verify({ flags, env: {}, host: host({}) });
assert.deepEqual(
[
vi.mocked(verifyTestMuConnection).mock.calls[0]?.[0].username,
vi.mocked(verifyTestMuConnection).mock.calls[0]?.[0].accessKey,
],
['opt-user', 'opt-key'],
);
await assert.rejects(
async () =>
await testmu().connection!.resolve({
flags,
stateDir: '/state',
cwd: '/work',
env: {},
host: host({}),
}),
{ code: 'INVALID_ARGS' },
);
});

test('the v2 declaration matches the v1 manifest that hosts without the v2 contract read', () => {
const v1 = manifest.agentDevicePlugin;
assert.equal(testmu().provider, v1.provider);
assert.deepEqual(testmu().connection?.capabilities, v1.connection);
assert.deepEqual(testmu().credentials.variables, v1.credentialVariables);
assert.equal(v1.apiVersion, 1);
});

test('a credentials object missing one key falls back to the environment for that key', async () => {
const captured = capturedAuthorization();
await listArtifacts(testmu({ credentials: { username: 'opt-user' } as never }), {
LT_USERNAME: 'env-user',
LT_ACCESS_KEY: 'env-key',
});
assert.equal(captured.read(), basic('opt-user', 'env-key'));
});
65 changes: 64 additions & 1 deletion packages/provider-testmu/src/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,64 @@
export { default } from './plugin.ts';
import type { AgentDevicePlugin, ProviderPluginHost } from 'agent-device/plugins';
import manifest from '../package.json' with { type: 'json' };

type ConnectionCapabilities = NonNullable<AgentDevicePlugin['connection']>['capabilities'];

export type TestmuOptions = {
/** Used instead of `LT_USERNAME` and `LT_ACCESS_KEY` when set. */
credentials?: { username: string; accessKey: string };
};

// The connection and WebDriver code load when they are first used, so importing the package for its
// declaration stays small.
async function loadConnection(host: ProviderPluginHost) {
return (await import('./connection.ts')).createTestMuConnection(host);
}

/**
* TestMu AI as a plugin. The credentials come from `options.credentials`, with the
* `LT_USERNAME` and `LT_ACCESS_KEY` environment variables as the fallback for each key the options
* leave out. The plugin declares only the variables it reads from the environment; credentials passed
* as options are not part of the environment the daemon fingerprints.
*/
function testmu(options: TestmuOptions = {}): AgentDevicePlugin {
const { credentials } = options;
const withCredentials = (host: ProviderPluginHost): ProviderPluginHost =>
credentials
? {
...host,
// A key the caller left out falls back to the environment instead of shadowing it.
env: {
...host.env,
...(credentials.username ? { LT_USERNAME: credentials.username } : {}),
...(credentials.accessKey ? { LT_ACCESS_KEY: credentials.accessKey } : {}),
},
}
: host;
// Only the variables the options do not supply come from the environment, so only those are
// part of the credential fingerprint.
const environmentVariables = [
...(credentials?.username ? [] : ['LT_USERNAME']),
...(credentials?.accessKey ? [] : ['LT_ACCESS_KEY']),
];
return {
apiVersion: 2,
provider: 'testmu',
credentials: { variables: environmentVariables },
connection: {
// The declaration hosts without the version 2 contract read from package.json; the registry
// validates it when the plugin loads.
capabilities: manifest.agentDevicePlugin.connection as ConnectionCapabilities,
resolve: async (context) =>
(await loadConnection(withCredentials(context.host))).resolve(context),
verify: async (context) =>
await (await loadConnection(withCredentials(context.host))).verify(context),
},
setup: async (host) => ({
webDriver: (await import('./testmu-webdriver.ts')).createTestMuWebDriver(
withCredentials(host),
),
}),
};
}

export { testmu as default };
Loading
Loading