Skip to content

refactor: declare the profile fields each provider consumes, once - #3168

Merged
thymikee merged 10 commits into
callstack:mainfrom
LambdaTest:split/profile-fields
Oct 5, 2026
Merged

thymikee merged 10 commits into
callstack:mainfrom
LambdaTest:split/profile-fields

Conversation

@amankansal-lt

@amankansal-lt amankansal-lt commented Oct 3, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Split out of #3118 and stacked on #3165. Please review only the top three commits. This is the single consumed-field admission you asked us to keep in core.

  • A new @agent-device/contracts/provider-profile-fields export holds:
    • ProviderProfileFieldDeclaration, a full consumed/refused record over the profile fields.
    • rejectRefusedProviderProfileFields.
  • Each provider declares the fields it consumes: BrowserStack, AWS Device Farm (CLOUD_WEBDRIVER_PROFILE_FIELDS) and Limrun (LIMRUN_PROFILE_FIELDS). The runtime's profileFields is required.
  • That one declaration is enforced on connect, direct leases.allocate, remote-config, and repeat allocation of a live lease.
  • If a repeat allocation fails, the reused lease is no longer released.
  • Review nit [1]: PROVIDER_PROFILE_FIELD_FLAGS is the single field-to-flag map, and the BrowserStack device-feature specs derive their flags from it.

Behaviour changes (nit [5]): flags that were silently ignored are now refused:

  • AWS Device Farm refuses --provider-app, --provider-os-version (and its --os-version alias), --provider-project and --provider-build.
  • BrowserStack refuses --aws-*.
  • Limrun refuses every flag except --provider-app.

None of the documented examples or help text use a refused flag.

Validation

  • pnpm check:affected --run passes, including replay-compat with release tags fetched.
  • eager-closure-budgets passes.

🤖 Generated with Claude Code

Review in cubic

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 32 files

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread src/daemon/handlers/lease.ts Outdated
Comment thread src/__tests__/limrun-runtime.test.ts
Comment thread src/daemon/handlers/__tests__/lease.test.ts
Comment thread packages/contracts/src/remote-config-fields.ts
@thymikee

thymikee commented Oct 3, 2026

Copy link
Copy Markdown
Member

The code in 54890d4 looks correct to me, and the one check on this PR is green. I did not run the eager-closure budgets, the layering scan or the test suites. I also did not run it against a live cloud provider. The change refuses before any provider contact, so I did not ask for that.

Nothing here blocks merge. Cubic's review of this head and #3165 landing first are what remain before merge. One state I did not check: a lease that is active in the registry with no in-memory provider session, for example a recovered Limrun lease. There, a failed re-creation now keeps the lease until its TTL instead of releasing it. Could you confirm whether that state can happen?

Not blocking, take or leave: the new ./provider-profile-fields contracts subpath and the dynamic import in runtime-session.ts protect nothing, because browserstack-device-features.ts already imports @agent-device/contracts/remote and @agent-device/kernel/errors statically; rejectRefusedProviderProfileFields could live in remote-config-fields.ts behind the existing remote facade. The provider-to-declaration link is stored twice in provider-definitions.ts, in CLOUD_WEBDRIVER_PROFILE_FIELDS and in definitions[].profileFields, and createRuntime takes back the profileFields its own definition already holds. The refused-repeat stub in lease.test.ts throws a hand-written message instead of calling rejectRefusedProviderProfileFields with a declaration.

Could the checker and type sit next to PROVIDER_PROFILE_FIELD_FLAGS in the contracts remote facade, with each WebDriver definition carrying one declaration and the CLI lookup derived from it? The total per-provider declaration is the right owner and the alias copy in contracts is justified, since contracts cannot depend on command-registry and the sync test covers it. This is a local restructure inside the PR.

The four earlier inline threads from Cubic are fixed at this head, so you can resolve them: #3168 (comment), #3168 (comment), #3168 (comment) and #3168 (comment).

@amankansal-lt

Copy link
Copy Markdown
Contributor Author

Now that #3165 has merged, I've rebased this onto main (83cabf2); the head is fe6d0e874. The old lease-TTL commit is dropped because #3165's squash already contains it, so the PR is only this PR's own commits now. In lease.ts, main's provider-only work pass and this PR's reused-lease rules merged without conflicts.

The rebase surfaced two gate failures, each fixed in its own commit with no behaviour change:

  • 9e6591f4f: fallow flagged the lease_allocate handler for complexity. The failure handling (release a new lease, keep a reused one, release a reused one whose requester cancelled) moves into settleFailedAllocation.
  • fe6d0e874: cloud-connect-profile.test.ts is over the test-file size tripwire, so the two new connect refusal tests move to cloud-connect-profile-fields.test.ts.

pnpm check:affected --base main --run passes all runnable checks, and eager-closure-budgets passes.

On the recovered-lease question: that state can't happen.

  • LeaseRegistry lives only in memory (src/daemon/lease-registry.ts:48-54); allocateLease and refreshLease are its only writers, and nothing loads it from disk. After a daemon restart the registry is empty, so reused is false.
  • Recovered leases come from expired-provider-leases.json (src/daemon/provider-lease-expiry.ts) into the releaser's pending set. That path only ever releases them (for Limrun, releaseRecoveredSession deletes the instance by label); it never reactivates them.
  • Limrun drops a session only together with its lease (release(), packages/provider-limrun/src/runtime.ts:290-296), or at daemon shutdown.

The only way to get an active lease with no provider session is two allocations at once for the same run and client. While the first is still waiting on the provider, the second sees the lease as reused. If the second fails, keeping the lease is correct; releasing it would pull the lease out from under the first allocation, which would then return an inactive lease and leave its instance without an owner.

A related pre-existing issue, outside this PR: in that same concurrent case, if the second Limrun allocation succeeds, sessions.set overwrites the first instance (runtime.ts:249-266), and the first instance is orphaned. I can open a separate issue or PR if you'd like.

The non-blocking restructure (checker and type in the contracts remote facade, one declaration per WebDriver definition) is not in this push. I'm happy to do it here or in a follow-up, whichever you prefer.

amankansal-lt and others added 9 commits October 5, 2026 13:38
…r consumes

Lease allocation forwards every provider profile field, but only some
routes checked which ones a provider could act on. The connect builder
and the AWS Device Farm session preparation refused BrowserStack device
features, while the Limrun lease runtime, BrowserStack itself, and a
remote-config profile skipped any check. A typed client asking Limrun
for providerOsVersion '18.0' was therefore given whatever instance
Limrun picked without an error.

Each lease provider now declares, for every Cloud provider profile field,
whether it consumes or refuses it. The declaration is a total record over
CloudProviderProfileFields, so adding a field fails to build until every
provider decides. One refusal, rejectRefusedProviderProfileFields from the
new @agent-device/contracts/provider-profile-fields subpath, runs at lease
preparation (the WebDriver session manager for BrowserStack and AWS
Device Farm, and Limrun allocation) and at connect, so connect,
leases.allocate and remote-config profiles all fail the same way with
INVALID_ARGS naming the flag and the provider.

This replaces the BrowserStack-only device-feature check. AWS Device Farm
now also refuses the hub-only app, OS version, project and build flags it
never read, and BrowserStack refuses the AWS Device Farm flags.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… too

The daemon hands a run's repeat lease_allocate the lease it already
holds, and both the WebDriver runtime and the Limrun runtime returned
the live session for a known lease before checking the request's
profile fields. A second allocation on the same run that set a refused
field, such as awsProjectArn on BrowserStack, therefore succeeded.

Both runtimes now refuse before reusing a live session. Because a
refusal of that repeat request is a provider allocate failure, the lease
handler no longer releases a lease it reused when allocate throws; doing
so would end the run's lease and leave the session the first allocation
created without an owner.

The runtime's profileFields option is now required, and each provider
definition passes its own declaration into createRuntime, so a provider
that forgets to wire its declaration fails to compile instead of
silently refusing nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…the profile field map

The refusal's field-to-flag table and the flag column of
BROWSERSTACK_DEVICE_FEATURE_SPECS spelled the same flags twice.
PROVIDER_PROFILE_FIELD_FLAGS now lives beside CloudProviderProfileFields
and is exported from @agent-device/contracts/remote, which the
BrowserStack table already imports, so its rows read each flag from that
map without growing the provider-webdriver eager closure. The refusal
reads the same map. A command-registry test pins every entry to the
CLI's canonical flag for the field.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…celed

A repeat allocation reuses the run's live lease, and a provider refusal
leaves that lease in place. A requester that hangs up also surfaces as an
allocate rejection, because the request signal aborts with the typed
cancellation, so the handler preserved the lease for a requester that
would never release it, and the provider session from the first
allocation lingered until the TTL sweep.

Release the lease and its provider session through the gone-requester
path when the request was canceled, reused or not. Only a refusal while
the requester still waits preserves the reused lease. The refused-repeat
test now also asserts the first allocation's provider session is still
owned by the lease.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The refusal builder emits INVALID_ARGS with the refused flags in
details.flags. Key the runtime, lease, connect and adapter tests on
those typed details so rewording the message or a provider label does
not break them. The builder's own unit tests still pin the message.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A refusal named only the canonical --provider-* flag, so a user who set
the field through an alias such as --os-version was told to drop a flag
they never typed. The flags bag keeps the field, not the spelling, so
the message now lists every accepted spelling, for example
"--provider-os-version (--os-version)". details.flags stays canonical.

Contracts cannot depend on the command registry, so the aliases live in
PROVIDER_PROFILE_FIELD_FLAG_ALIASES, and a command-registry test checks
that map against the aliases the CLI parses.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Main's provider-only work pass and the reused-lease rules met in the
lease_allocate handler and pushed it over the complexity threshold.
Move the failure handling (release a new lease, keep a reused one,
release a reused one whose requester left) into settleFailedAllocation.
No behaviour change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
cloud-connect-profile.test.ts is over the test-file size tripwire and may
not grow. The two new refusal tests go to cloud-connect-profile-fields.test.ts,
which mirrors the profile builders they exercise.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Allocate and connect against an existing instance (LIM_IOS_INSTANCE_URL and
token, no API key) accept the consumed --provider-app and refuse the rest,
like a created instance.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@amankansal-lt

Copy link
Copy Markdown
Contributor Author

Rebased onto main again (db56bb77d); the head is now 238672997.

  • Conflict: the only one was packages/provider-limrun/src/runtime.ts, against feat(limrun): drive an existing instance with its own URL and token #3173, which splits allocate into attaching an existing instance and creating a new one. The profile-field refusal now runs at the top of allocate, so it covers both the attach and the create path, and it still runs before a live session is reused.
  • New test: 238672997 covers the attach path, in the runtime and at connect. Moving the refusal back into createSession makes it fail.
  • New consumed fields: none. feat(limrun): drive an existing instance with its own URL and token #3173's inputs (LIM_*_INSTANCE_*, LIMRUN_KEEP_ALIVE) are environment variables, not profile fields, so LIMRUN_PROFILE_FIELDS is unchanged.
  • Gates: pnpm check:affected --base main --run passes all runnable checks, and fallow, the eager-closure budgets and the test-file size ratchet all pass.

One question I noticed but didn't change, since it's #3173's behaviour: on the attach path, --provider-app is accepted (Limrun consumes it), but nothing installs it on the attached instance. Should it be refused, or warned about, when an instance is attached?

@thymikee

thymikee commented Oct 5, 2026

Copy link
Copy Markdown
Member

This PR is ready at 2386729. The conflict from the earlier review (#3168 (comment)) is resolved, and all 16 checks pass on this head. There are no conflicts, and it needs a maintainer approval to merge.

Not blocking, and you can take or leave these: the doc comment for releaseAllocationForGoneRequester now sits above settleFailedAllocation in https://github.com/callstack/agent-device/blob/2386729/src/daemon/handlers/lease.ts#L199, so moving settleFailedAllocation above that comment or below the function would fix it. Also, LIMRUN_PROFILE_FIELDS in https://github.com/callstack/agent-device/blob/2386729/packages/provider-limrun/src/runtime.ts#L267 declares providerApp as consumed, but attachSession never reads initialApp, so --provider-app is accepted and ignored on an attached instance; should that wait for the per-route validation in #3118?

I did not run the tests locally, and I did not re-review the four threads resolved before 54890d4. I did not run a live Limrun attach. A refused flag would fail before any provider client is created, so a unit test covers that route.

@thymikee thymikee added the ready-for-human Valid work that needs human implementation, judgment, or maintainer merge label Oct 5, 2026
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@amankansal-lt

Copy link
Copy Markdown
Contributor Author

Thanks. In 3d27e71, the releaseAllocationForGoneRequester doc comment is back on its function; it's a comment-only change.

On Limrun's attached instances: yes, I'd leave that for the per-route validation. Today --provider-app is accepted and ignored there (the CLI help already says installs still need LIMRUN_API_KEY). The fix is either to refuse providerApp when an instance is configured for the platform, or to preinstall onto the attached instance. Either one changes #3173's behaviour, so I'd rather it come as its own change than ride along here.

@thymikee

thymikee commented Oct 5, 2026

Copy link
Copy Markdown
Member

Rechecked at 3d27e71. The only change since 2386729 moves the releaseAllocationForGoneRequester doc comment back onto its function in src/daemon/handlers/lease.ts; no code changed, so the earlier clean verdict holds. Checks pass and there are no conflicts. Agreed that refusing or preinstalling --provider-app on attached Limrun instances belongs in its own change.

amankansal-lt added a commit to LambdaTest/agent-device that referenced this pull request Oct 5, 2026
Reverts 62e814c. The maintainer judged the connect-route check
redundant: plugin resolve and lease allocation both refuse those fields,
and callstack#3168 owns profile-field admission.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@thymikee
thymikee merged commit f07feda into callstack:main Oct 5, 2026
16 checks passed
amankansal-lt added a commit to LambdaTest/agent-device that referenced this pull request Oct 5, 2026
callstack#3168 landed the provider profile-field declarations and their refusal on
main. This PR carried an earlier copy of the same work, so the conflicts
resolve to main's version throughout: rejectRefusedProviderProfileFields
with alias-aware messages, PROVIDER_PROFILE_FIELD_FLAGS and its aliases in
remote-config-fields, the BrowserStack, AWS Device Farm and Limrun
declarations (including the attach-path refusal), settleFailedAllocation
in the lease handler, and main's tests.

What stays from this PR is the providerDeviceType field TestMu introduces:
its flag map entry, a 'refused' row in each existing declaration, and a
'consumed' row in the test declarations. The BrowserStack device-type
integration test is dropped; main's repeat-allocation test covers that
refusal path.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
thymikee added a commit that referenced this pull request Oct 7, 2026
* refactor(provider-webdriver): share hub upload and app-reference helpers

BrowserStack's app upload, install adapter, --provider-app resolution,
session-details URL artifacts, and orientation check were written for
one vendor. A second hosted Appium hub needs the same mechanics with a
different form field, reference scheme, and response shape, so move
them into shared helpers and have BrowserStack use them:

- webdriver-utils.ts: postHubAppUpload, createHubUploadApp,
  resolveHubAppReference, appFileUploadForm, asRecord,
  readProviderJsonBody, and requireProviderDeviceOrientation.
- artifact-results.ts: urlArtifactFromDetails.
- browserstack.ts: resolveBrowserStackAppReference, moved out of
  provider-definitions.ts.

Two small BrowserStack behaviour changes come with the shared code:

- An upload response that is not JSON (a gateway error page, an empty
  body) now fails with a typed COMMAND_FAILED that carries the HTTP
  status, instead of a raw JSON SyntaxError.
- The http(s) scheme of a --provider-app URL is matched
  case-insensitively, so HTTPS://... is passed through to the hub rather
  than being treated as a local path.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(provider-webdriver): bound and type session-details lookups

The BrowserStack session-details lookup behind `artifacts` had no
deadline, so a stalled API call could hang the command indefinitely. A
transport failure or a body that was not JSON surfaced as an untyped
fetch or SyntaxError, and a JSON array passed the object check and was
read as session details.

Add fetchProviderSessionDetails to webdriver-utils.ts and use it for
BrowserStack. It sends basic auth with a 15 second deadline and reports
every failure as COMMAND_FAILED: a timeout or network error with a retry
hint and the original error as its cause, and a non-2xx answer or a body
that is not a JSON object with the HTTP status and the parsed response.

Connection verification gets the same treatment through
fetchProviderVerificationJson, which BrowserStack now uses in place of
its private fetch. Behaviour is unchanged: 401/403 is UNAUTHORIZED with
a credential hint, any other HTTP failure points at the provider's
service status, and a transport failure points at network access. A
new test pins the two non-credential hints. sameOsVersion moves
alongside it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(provider-webdriver): add TestMu AI emulator and simulator provider

Add `testmu`, a hosted WebDriver provider for TestMu AI (formerly
LambdaTest) virtual devices: Android emulators and iOS simulators behind
the TestMu AI Appium hub. It follows the BrowserStack shape:
`connect testmu` verifies and saves a local profile, and `open` creates
the hosted session. The service hostnames still carry the lambdatest.com
domain.

connect
- Reads LT_USERNAME and LT_ACCESS_KEY, the variables TestMu AI SDKs use.
- Checks the device and OS version against the public virtual-device
  catalog. The match is exact because the hub rejects `18` for a device
  listed as `18.0`; the error lists the versions the device offers.
- Uses the authenticated app listing as the credential check, and looks
  an lt:// id up in the list for the session's runtime (`emulator` or
  `simulator`). An id that is not listed is reported as configured,
  since TestMu AI validates it at session creation.
- Verifies against TESTMU_API_ENDPOINT when it is set, as the runtime
  does, and composes catalog and listing URLs with URL so a base or
  override that carries a query keeps it.
- Never creates a session.

Sessions
- Standard Appium keys stay `appium:`-prefixed; everything vendor-
  specific goes in `lt:options`, merged per key with any configured
  `lt:options`. `isRealMobile: false` and `w3c: true` are applied last,
  so configuration cannot move the session to another device pool or
  off the W3C dialect agent-device speaks.
- `appiumVersion` is sent only when --provider-appium-version pins one;
  otherwise TestMu AI starts its default server for the device.
- Orientation, geo-location, timezone, Appium version, language, and
  locale map onto `lt:options` through a table. The BrowserStack-only
  network-profile, custom-network, and no-resign flags are refused by
  flag name at connect and at session preparation, instead of being
  silently dropped.

Apps
- --provider-app takes an lt:// id, an http(s) URL, or a local path. The
  hub only accepts lt:// references, so a URL is handed to the upload
  API to fetch (`storage=url`) and a local file is uploaded.
- An unzipped iOS `.app` directory is rejected before any request, with
  a hint to zip it.
- Only a well-formed lt:// reference or app id in the upload response
  counts as success.

Artifacts
- Read from the session-details API through the shared bounded lookup.
  A 404 reads as pending until TestMu AI publishes the details.
  `console_logs_url` is the device log on virtual devices.
- Session video, Appium, network, and command logs, screenshots, and
  the dashboard link are returned once at least one artifact URL exists.

TESTMU_WEBDRIVER_ENDPOINT, TESTMU_APP_UPLOAD_ENDPOINT, and
TESTMU_API_ENDPOINT redirect the endpoints.

The session, upload, verification, and device-feature modules are loaded
with dynamic import, so the package entry's eager module graph does not
grow. The CLI reaches the device-feature checks through a new
`./testmu-device-features` subpath export. A .fallowrc entry covers the
exports that are read only through the dynamic import. `connect testmu`
is listed in the connect usage, the remote help topic, and the artifacts
provider description.

Co-authored-by: gautam-jain-dev <gautamj@lambdatest.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(provider-webdriver): support TestMu AI real devices

TestMu AI serves real devices and virtual devices from the same Appium
hub; `lt:options.isRealMobile` selects the pool. Add
`--provider-device-type real|virtual` to choose it. The default is
`virtual`, so existing `testmu` profiles and sessions keep their current
behaviour.

The value is a cloud provider profile field like the others. It is
defined in contracts as PROVIDER_DEVICE_TYPES and carried through the
lease_allocate projection, request overrides, the remote-config schema,
`connect`, the CLI request flags, and the session doctor's provider
keys. It reaches session preparation from a `connect testmu` profile and
from `client.leases.allocate({ providerDeviceType })`.

For `real`, TestMu AI sessions:
- Set `isRealMobile: true`. It is applied after any configured
  `lt:options`, so configuration cannot switch pools.
- Upload through the real-device upload API. It has its own override,
  TESTMU_REAL_DEVICE_APP_UPLOAD_ENDPOINT; TESTMU_APP_UPLOAD_ENDPOINT
  keeps redirecting virtual-device uploads only. An `.app` directory is
  refused with a hint to pass a signed .ipa.
- Verify against the real-device catalog
  (`capability/generator?isVirtualDevice=false`). It lists devices
  directly under the platform key rather than under `app.devices`. The
  OS version must still match exactly, and real iOS devices are listed
  by major version, such as `18`. A catalog response without the
  expected pool shape fails with a typed error.
- Look an lt:// id up in the real-device app list (`type=android` or
  `type=ios`), the same way virtual uploads are looked up under
  `emulator` or `simulator`.

BrowserStack, AWS Device Farm, and Limrun refuse the flag at connect.
BrowserStack and AWS Device Farm also refuse it at session preparation,
which the typed client and hand-written profiles reach without
`connect`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(provider-webdriver): upload the archive of materialized iOS builds

Install from a remote source materializes an iOS build by extracting the
`.app` bundle from a zipped simulator build or an .ipa. The WebDriver
deployment runtime handed that extracted `.app` directory to the
provider's uploader. Hosted upload APIs take a file, not a directory, so
the upload could not succeed.

When the materialized artifact is an iOS `.app` extracted from a `.zip`
or `.ipa` archive, upload the archive it came from. Every other case
uploads the installable path as before: no archive, an archive of
another type, or an Android build. A provider without an uploader still
installs the extracted bundle path. The bundle id and launch target
hints are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs: add the TestMu AI device cloud guide

Add a TestMu AI guide next to the BrowserStack and AWS Device Farm ones
and link it from the sidebar. It covers credentials and `connect`, the
exact device and OS version match, app references and uploads, device
features, real devices with `--provider-device-type real`, the CLI and
Node.js client workflows, artifacts, and endpoint overrides.

List TestMu AI among the device clouds in the README, the device clouds
overview, the client API page, and the command reference.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(daemon): start a lease's TTL when its allocation completes

The registry records a lease, and stamps its expiry, before the lease
lifecycle provider allocates the session behind it. Hosted providers can
spend 30-80 seconds creating that session, so a lease on the default
one-minute inactivity window was expired, or nearly so, by the time its
client received it: the next command failed with "Lease is not active",
release reported nothing to release, and the paid provider session was
left running. The expiry sweep could also reap the lease while the
provider was still allocating.

Hold a lease work pass for the duration of the provider allocation, the
same protection admitted request work gets. The lease cannot expire
underneath the allocation, and ending the pass while the requester is
still waiting renews the lease for its own TTL from that moment. The
response now carries the renewed lease. A requester that hung up still
protects nothing, and its allocation is released as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(provider-webdriver): declare the profile fields each provider consumes

Lease allocation forwards every provider profile field, but only some
routes checked which ones a provider could act on. The connect builders
and the BrowserStack/AWS session preparation refused flags they did not
own, while the Limrun lease runtime and a remote-config profile skipped
that check. A typed client asking Limrun for providerDeviceType 'real'
was therefore given a simulator without an error.

Each lease provider now declares, for every Cloud provider profile field,
whether it consumes or refuses it. The declaration is a total record over
CloudProviderProfileFields, so adding a field fails to build until every
provider decides. One refusal, derived from that declaration, runs at
lease preparation (the WebDriver session manager for BrowserStack, AWS
Device Farm and TestMu AI, and Limrun allocation) and at connect, so
connect, leases.allocate and remote-config profiles all fail the same
way with INVALID_ARGS naming the flag and the provider.

This replaces the per-provider TestMu-only, TestMu-unsupported and
BrowserStack-only checks, the hand-copied complement of the BrowserStack
feature table, and the ./testmu-device-features package subpath. AWS
Device Farm now also refuses the hub-only app, OS version, project and
build flags it never read.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(provider-webdriver): let the Apple materializer name the uploadable file

The WebDriver deployment runtime guessed what to upload for a materialized
iOS build from file extensions: an extracted .app with a .zip or .ipa
archive uploaded that archive. The archive it saw is the outermost one the
source arrived as, so a URL .zip wrapping an .ipa uploaded the wrapper zip
instead of the .ipa, and a zip holding a .app.tar.gz uploaded a zip with no
app bundle at its top.

Materialization now records the archive an installable was extracted from
directly, and the Apple materializer declares the file a hosted provider
uploads: the .ipa itself, or the zip a simulator .app was extracted from.
It names nothing when no such file exists. The deployment runtime uploads
the declared file and otherwise the installable path, with no inference of
its own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(provider-webdriver): tighten app-reference, endpoint and upload input handling

Several inputs reached a hosted provider in a shape it could not use, or
failed with an untyped error:

- An empty `lt://` passed as a TestMu AI app reference and reached session
  creation. A reference is now validated against the app-id grammar, and
  an invalid one is INVALID_ARGS on connect and at session preparation.
- `LT://APP1` or `BS://id` was treated as a file path. App schemes now
  match case-insensitively and are canonicalized to the spelling the hub
  accepts.
- A directory given as `--provider-app` reached the upload and failed
  with a raw EISDIR; it is now INVALID_ARGS with a hint.
- A missing path given straight to the TestMu AI upload failed with a
  bare ENOENT; it now gets the same typed refusal as a directory.
- Session-detail URLs were built by string concatenation, so a query on
  TESTMU_API_ENDPOINT or the BrowserStack details endpoint swallowed the
  route. Routes are now appended to the URL path, keeping the query.
- A whitespace-only artifact URL was reported as a ready artifact; it is
  now treated as absent, and URLs are trimmed.
- A 2xx connection-verification answer that is not JSON was reported as
  a network failure. It is now COMMAND_FAILED with the HTTP status, and
  each hub supplies its own service-status hint.

The connect help and the TestMu AI guide now say what connect checks for
`--provider-app` and what is only validated when the session is created.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(provider-webdriver): exercise in-flight deadlines and aborts for real

The BrowserStack session-details timeout test threw a prebuilt
TimeoutError from the fetch stub, so it passed even with the deadline
removed. The fetch now stays pending until the signal the lookup armed
aborts, and the test checks that signal is the 15 second deadline.

The TestMu AI upload cancellation test aborted before the upload reached
fetch, so it never covered a request in flight. It now waits for the
stub to start before aborting.

The connect suites shared a copied connectWithGeneratedProviderProfile
helper; it now lives in the shared test utilities.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(provider-webdriver): refuse profile fields on a repeat allocation too

The daemon hands a run's repeat lease_allocate the lease it already
holds, and both the WebDriver runtime and the Limrun runtime returned
the live session for a known lease before checking the request's
profile fields. A second allocation on the same run that set a refused
field, such as providerDeviceType on BrowserStack, therefore succeeded.

Both runtimes now refuse before reusing a live session. Because a
refusal of that repeat request is a provider allocate failure, the lease
handler no longer releases a lease it reused when allocate throws; doing
so would end the run's lease and leave the session the first allocation
created without an owner.

The runtime's profileFields option is now required, and each provider
definition passes its own declaration into createRuntime, so a provider
that forgets to wire its declaration fails to compile instead of
silently refusing nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(provider-webdriver): validate and canonicalize app references at connect

Connect only checked that an lt:// or bs:// reference had something after
the scheme, so `lt://a b` and `lt://a/b` were saved and failed only when
the hub created the session. Both hubs' reference grammars now live in
the light providers module, and connect, session preparation and the
TestMu AI upload reader all validate against them.

Connect also verified the raw spelling typed on the command line: the
generated profile stored `lt://APP1` for `LT://APP1`, but the flags
handed to verification were the raw CLI flags laid over the profile, so
the uploaded-app lookup missed and reported a local artifact. The
canonical reference now wins in the flags verification reads.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(provider-webdriver): refuse a directory before any hosted upload

A materialized build that names no uploadable file falls back to its
installable path, which for iOS is the extracted .app directory. The
deployment runtime handed that to the hub's uploader, and BrowserStack's
read it as a file and failed with a raw EISDIR.

The deployment runtime now refuses a directory before calling any hub's
uploader, with INVALID_ARGS naming the provider and path and a hint to
zip the .app or pass the .ipa, .apk, or .aab.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(cli): refuse a non-URL install-from-source source with a typed error

install-from-source sends its positional as a URL source. A local path
reached the iOS trusted-host check, where `new URL()` threw a TypeError
that surfaced as an untyped UNKNOWN "Invalid URL".

The CLI now refuses a positional that is not an http(s) URL with
INVALID_ARGS and points at `install <app> <path>` for local builds, and
the trusted-host check reports an unparsable source as INVALID_ARGS
instead of throwing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat: support provider-owned plugin connections and WebDriver adapters

* chore(gates): expose the optional WebDriver plugin configuration

* feat: load TestMu from its separately installed provider plugin

* chore(gates): declare TestMu plugin build dependencies and analysis entrypoints

* fix: retain plugin tests through development-only package exports

* chore(gates): track TestMu packaging fixtures and workspace test dependencies

* chore(gates): smoke the optional packed WebDriver plugin SDK

* perf: load the shared WebDriver engine on demand

* chore(gates): bound the optional plugin SDK declarations

* fix: consolidate TestMu plugin helper imports

* fix(testmu): trim credentials and log URLs, canonicalize lt:// everywhere

Whitespace-only LT_USERNAME/LT_ACCESS_KEY now fail as missing in the
runtime, matching connect. Verification of a hand-authored profile,
upload responses, and app-list ids share one case-insensitive lt://
canonicalization, and a blank console_logs_url falls back to
device_logs_url.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(testmu): export credentials in help and note the fixed app listing

The help flow's bare assignments did not reach the next agent-device
process. The endpoint overrides do not move the app listing connect
uses for the credential check, so say so instead of implying a private
deployment is fully redirectable.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(kernel): require a real Error behind the AppError brand

A plain object carrying the global brand symbol passed instanceof
AppError and left normalizeError with no code or message. Package copies
share the realm's Error.prototype, so the plugin-copy case still holds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(plugins): refuse a primitive factory result as an invalid plugin

'webDriver' in <primitive> threw a raw TypeError past the loader's
INVALID_ARGS validation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(plugins): archive into a fresh zip, not over a stale one

zip -r updates an existing archive in place, so leftover entries from an
earlier attempt would ride along in the upload.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(connect): look plugin capabilities up in the caller's environment

isConnectProviderName already took an env; connectionProviderCapabilities
read process.env, so under another plugin home the two disagreed and a
plugin provider fell back to the builtin capability shape.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(kernel): type the AppError brand lookup on a narrowed Error

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(plugins): reserve provider ids from one list

assertUniquePluginProviders merged RESERVED_PLUGIN_PROVIDERS into
whatever its callers passed, and the daemon passed the overlapping
DEFAULT_PROVIDER_RUNTIME_REQUIRED_IDS. Every caller now passes
RESERVED_PLUGIN_PROVIDERS, which takes the WebDriver ids from
CLOUD_WEBDRIVER_PROVIDERS, and a test pins every bundled runtime id
into it. plugins add/update no longer loads the provider runtimes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(plugins): name the WebDriver factory result check

Keeps instantiateProviderPlugin under the Fallow complexity threshold
after the object guard.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore(fallow): stop suppressing statically imported TestMu helpers

connection.ts imports readTestMuDeviceFeatureFields and
readTestMuDeviceType statically, so Fallow sees them; only the lazily
read buildTestMuDeviceFeatureCapabilities still needs the exemption.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test: allocate through a WebDriver plugin and assert typed ownership

The WebDriver plugin fixture now declares its profile fields and the
test allocates a session through the shared engine, refusing a field it
marks refused before any request. The refused repeat allocation also
asserts session-1 still resolves to the live lease, and the Limrun
refusal checks details.provider and details.flags instead of copy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(connect): refuse a WebDriver plugin's refused fields before resolve

Builtin connect routes run rejectRefusedProviderProfileFields against
their declaration; the plugin route left it to the plugin. A
{ webDriver } plugin already hands core a total profileFields
declaration, so the connect route now applies it before the plugin's
resolve. Raw runtime plugins carry no declaration and still validate
their own flags.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(testing): say when to run the packed provider plugin check

check-provider-plugin.mjs needs pnpm and a built core, which the Node
22.12 package lane cannot run and whose script body CI credits
verbatim, so it is a manual pre-push check for plugin package changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs: document the plugin package check in its script, not testing.md

testing.md is over the agent-guidance byte budget with the note, so the
how and when now sit in the script header.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* revert: let resolve and allocate own plugin profile-field refusal

Reverts 62e814c. The maintainer judged the connect-route check
redundant: plugin resolve and lease allocation both refuse those fields,
and #3168 owns profile-field admission.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* revert: keep the AppError brand check as it was

Reverts c553ed7 and aab5684. Plugins are trusted in-process code, so
a forged brand is not a threat the maintainer wants handled here.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* revert: drop the plugin host archive fix with the apple host block

Reverts 1d52862; ProviderPluginHost.apple is removed next.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(plugins): remove the unused apple block from the plugin host

No plugin calls host.apple, and archiveDirectory copied the Limrun
dependency's zip helper. A later plugin that needs Apple packaging can
share one extracted helper with Limrun.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(webdriver): one owner for lease-flag and credential readers

requireRequest, requireRequestPlatform, requireFlag, readFlag and
requireEnv move from provider-definitions.ts into webdriver-utils.ts and
are exported from the provider-webdriver/plugin subpath; the TestMu
plugin drops its copies. requireEnv keeps the trim-empty rule, so a
whitespace-only BrowserStack credential now also fails as missing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(testmu): import session and device-feature modules statically

connection.ts already imports them statically, so the dynamic imports
in plugin.ts saved nothing; the Fallow exemption for the lazy read goes
with them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* build(testmu): typecheck the plugin against core source, not dist

CI typechecks before it builds, so agent-device/plugins and
agent-device/plugins/webdriver resolved to dist types that did not
exist yet and ProviderPluginHost became any. Both tsconfigs map the two
specifiers to src/sdk, as examples/sdk does; provider-testmu maps
through its workspace link so the files stay outside its rootDir.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore(fallow): credit the testmu plugin's type-only agent-device link

With the tsconfig mapping to core source, Fallow resolves the plugin's
agent-device/plugins imports to src/sdk files and reported the
devDependency unused, which a clean checkout surfaced.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(testmu): assert only Limrun's device-type refusal at connect

Main's refusal names a field's aliases too, so the combined message this
test matched no longer exists, and main already covers Limrun refusing
the OS version. Keep the part this PR adds: --provider-device-type is
refused by Limrun.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(providers): share the lower-case scheme rule for hub app references

canonicalTestMuAppReference repeated the rule canonicalBrowserStackAppReference
applies: a hub matches only the lower-case scheme, so BS:// and LT:// are
rewritten to bs:// and lt://. Both now call canonicalSchemeReference from the
dependency-free provider-webdriver/providers subpath, so neither eager
closure grows. Behaviour is unchanged: BrowserStack still returns undefined
without the scheme, and TestMu still returns the value as given.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(provider-webdriver): export the plugin's webdriver-utils readers in one block

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(providers): refuse a lease when the daemon holds other plugin credentials

A provider plugin can declare agentDevicePlugin.credentialVariables in its
manifest. providerCredentialFingerprint falls back to that declaration for
providers without a built-in reader, so the client (shell env) and the
daemon (startup env) both fingerprint plugin credentials without loading
plugin code. TestMu declares LT_USERNAME and LT_ACCESS_KEY, which brings
it under the provider-credentials-changed refusal from #3207.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(plugins): split manifest connection and credential checks

Keeps readPluginManifest under the fallow complexity threshold, and types
the manifest fixture's version so the typecheck passes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs: explain installation of the TestMu package

* chore(gates): align TestMu package with workspace releases

* docs: explain daemon restart after plugin installation

* docs: show the complete plugin connection manifest

* chore(gates): deduplicate the merged package boundary fixture import

---------

Co-authored-by: Anurag Sharma <38938168+anurag-lambdatest@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Michał Pierzchała <thymikee@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ready-for-human Valid work that needs human implementation, judgment, or maintainer merge

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants