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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

## Unreleased

- Behavior change: `resolveHubAppReference`, shipped through `agent-device/plugins/webdriver`, now needs `cwd` to resolve a relative app path. Without it the call fails with `INVALID_ARGS` and `details.reason: provider_app_cwd_missing`; it used to resolve against the process's working directory. Absolute paths, URLs and provider references are unaffected. The daemon fills it from the client's working directory (or its host's) for every provider lease request, so `connect` and `lease_allocate` are unchanged.
- 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.
Expand Down
21 changes: 21 additions & 0 deletions packages/provider-webdriver/src/webdriver-utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,27 @@ test('the hub app resolver passes references through, uploads local files, and p
}
});

test('the hub app resolver refuses a relative app path when the request names no working directory', async () => {
const uploadFile = vi.fn(async () => 'hub://never');

await assert.rejects(
resolveHubAppReference({
service: 'Hub',
app: 'App.apk',
referenceLabel: 'a hub:// app id',
...hubReferenceGrammar,
uploadFile,
}),
(error: unknown) => {
assert.ok(error instanceof AppError);
assert.equal(error.code, 'INVALID_ARGS');
assert.equal(error.details?.reason, 'provider_app_cwd_missing');
return true;
},
);
assert.equal(uploadFile.mock.calls.length, 0);
});

test('appFileUploadForm carries a regular app file and refuses anything else typed', async () => {
const tempDir = await mkdtempForTest('agent-device-upload-form-');
try {
Expand Down
13 changes: 12 additions & 1 deletion packages/provider-webdriver/src/webdriver-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,7 @@ export async function resolveHubAppReference(options: {
const reference = options.parseReference(app);
if (reference !== undefined) return reference;
if (/^https?:\/\//i.test(app)) return app;
const appPath = path.resolve(options.cwd ?? process.cwd(), app);
const appPath = path.isAbsolute(app) ? app : path.resolve(requireAppCwd(options), app);
if (!fs.existsSync(appPath)) {
throw new AppError(
'INVALID_ARGS',
Expand All @@ -176,6 +176,17 @@ export async function resolveHubAppReference(options: {
return await options.uploadFile(appPath, options.signal);
}

// A relative app path needs the working directory of the client that named it; the daemon fills it
// from the request, and no ambient directory stands in for a request that names none.
function requireAppCwd(options: { service: string; app: string; cwd?: string }): string {
if (options.cwd !== undefined) return options.cwd;
throw new AppError(
'INVALID_ARGS',
`${options.service} --provider-app ${options.app} is a relative path and the request names no working directory; pass an absolute path.`,
{ providerApp: options.app, reason: 'provider_app_cwd_missing' },
);
}

const PROVIDER_API_TIMEOUT_MS = 15_000;

/** Service name and remediation hints a verification failure carries. */
Expand Down
24 changes: 21 additions & 3 deletions packages/remote/src/__tests__/daemon-artifacts-save-script.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
* remote request that would produce a file the caller can never see is refused here instead of
* appearing to succeed.
*/
import { createTestHostEnvironment } from '@agent-device/test-kit';
import assert from 'node:assert/strict';
import { test } from 'vitest';
import { AppError } from '@agent-device/kernel/errors';
Expand All @@ -28,7 +29,13 @@ function replayRequest(saveScript?: boolean | string) {

test('a remote daemon refuses --save-script, naming the caller/daemon split', async () => {
await assert.rejects(
async () => await prepareRemoteRequestArtifacts(replayRequest(true), REMOTE, NO_CANCELLATION),
async () =>
await prepareRemoteRequestArtifacts(
replayRequest(true),
REMOTE,
NO_CANCELLATION,
createTestHostEnvironment(),
),
(error: unknown) =>
error instanceof AppError &&
error.code === 'INVALID_ARGS' &&
Expand All @@ -43,20 +50,31 @@ test('a remote daemon refuses an explicit --save-script output path too', async
replayRequest('./flows/login.healed.ad'),
REMOTE,
NO_CANCELLATION,
createTestHostEnvironment(),
),
(error: unknown) => error instanceof AppError && error.code === 'INVALID_ARGS',
);
});

test('a local daemon leaves --save-script untouched', async () => {
const prepared = await prepareRemoteRequestArtifacts(replayRequest(true), LOCAL, NO_CANCELLATION);
const prepared = await prepareRemoteRequestArtifacts(
replayRequest(true),
LOCAL,
NO_CANCELLATION,
createTestHostEnvironment(),
);

assert.equal(prepared.flags?.saveScript, true);
assert.deepEqual(prepared.positionals, ['./flows/login.ad']);
});

test('a remote replay without --save-script passes through unchanged', async () => {
const prepared = await prepareRemoteRequestArtifacts(replayRequest(), REMOTE, NO_CANCELLATION);
const prepared = await prepareRemoteRequestArtifacts(
replayRequest(),
REMOTE,
NO_CANCELLATION,
createTestHostEnvironment(),
);

assert.deepEqual(prepared.positionals, ['./flows/login.ad']);
assert.equal(prepared.uploadedArtifactId, undefined);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
* command: the client resolves the real local artifacts root itself and redirects the daemon to
* a temp path it owns, exactly as it already does for `screenshot`/`record start`.
*/
import { createTestHostEnvironment } from '@agent-device/test-kit';
import assert from 'node:assert/strict';
import path from 'node:path';
import { test } from 'vitest';
Expand All @@ -29,6 +30,7 @@ test('a remote daemon redirects an explicit --artifacts-dir to a temp path it ow
testRequest('remote-device-artifacts/ad-test'),
REMOTE,
NO_CANCELLATION,
createTestHostEnvironment(),
);

const redirected = (prepared.flags as Record<string, unknown> | undefined)?.artifactsDir;
Expand All @@ -45,6 +47,7 @@ test('a remote daemon redirects the default artifacts directory too', async () =
testRequest(undefined),
REMOTE,
NO_CANCELLATION,
createTestHostEnvironment(),
);

const redirected = (prepared.flags as Record<string, unknown> | undefined)?.artifactsDir;
Expand All @@ -61,6 +64,7 @@ test('a remote daemon leaves an already-absolute --artifacts-dir as the download
testRequest('/ci/artifacts/ad-test'),
REMOTE,
NO_CANCELLATION,
createTestHostEnvironment(),
);

const redirected = (prepared.flags as Record<string, unknown> | undefined)?.artifactsDir;
Expand All @@ -73,6 +77,7 @@ test('a local daemon leaves --artifacts-dir untouched', async () => {
testRequest('remote-device-artifacts/ad-test'),
LOCAL,
NO_CANCELLATION,
createTestHostEnvironment(),
);

assert.equal(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ test('an aborted signal ends a legacy upload mid-stream instead of finishing the
await assert.rejects(
async () =>
await uploadArtifact({
env: process.env,
localPath: artifactPath,
baseUrl: server.baseUrl,
token: TEST_TOKEN,
Expand Down Expand Up @@ -92,6 +93,7 @@ test('an aborted signal before preflight refuses to ask the daemon for a ticket'
await assert.rejects(
async () =>
await uploadArtifact({
env: process.env,
localPath: artifactPath,
baseUrl: server.baseUrl,
token: TEST_TOKEN,
Expand Down Expand Up @@ -129,6 +131,7 @@ test('an upload with no signal behaves exactly as before', async () => {

try {
const uploadId = await uploadArtifact({
env: process.env,
localPath: artifactPath,
baseUrl: server.baseUrl,
token: TEST_TOKEN,
Expand Down
12 changes: 10 additions & 2 deletions packages/remote/src/__tests__/upload-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@ import { createHash, randomUUID } from 'node:crypto';
import fs from 'node:fs';
import http, { type IncomingMessage, type ServerResponse } from 'node:http';
import path from 'node:path';
import { uploadArtifact } from '../upload-client.ts';
import { uploadArtifact as uploadArtifactOnHost } from '../upload-client.ts';

const uploadArtifact = (options: Omit<Parameters<typeof uploadArtifactOnHost>[0], 'env'>) =>
uploadArtifactOnHost({ ...options, env: process.env });
import type { UploadProgressEvent } from '../upload-progress.ts';
import { runCmdSync, withCommandExecutorOverride } from '@agent-device/host-kit/command';
import { closeLoopbackServer, listenOnLoopback, mkdtempForTestSync } from '@agent-device/test-kit';
Expand Down Expand Up @@ -917,7 +920,12 @@ test.each([
const localPath = createTempFile('app.apk', `${flow}-payload`);
for (const leaseId of ['lease-1', undefined]) {
leases = new Map();
await uploadArtifact({ localPath, baseUrl: server.baseUrl, token: TEST_TOKEN, leaseId });
await uploadArtifact({
localPath,
baseUrl: server.baseUrl,
token: TEST_TOKEN,
leaseId,
});
const expected = routes.map((route) => [route, route === unleased ? undefined : leaseId]);
assert.deepEqual([...leases], expected);
}
Expand Down
2 changes: 1 addition & 1 deletion packages/remote/src/__tests__/upload-progress.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ function renderEvents(events: UploadProgressEvent[]): string {
const isTTYDescriptor = Object.getOwnPropertyDescriptor(process.stderr, 'isTTY');
Object.defineProperty(process.stderr, 'isTTY', { configurable: true, value: true });
try {
const sink = createStderrUploadProgressReporter();
const sink = createStderrUploadProgressReporter(process.env);
assert.equal(sink !== undefined, true);
for (const event of events) sink!(event);
} finally {
Expand Down
Loading
Loading