Skip to content

Per-tenant ATS accounts — Technical Reference

Most ATS platforms (Workday, iCIMS, Greenhouse, and others) put up a per-tenant account wall: each employer's instance requires its own candidate account before the rest of the application form is reachable. This document describes how WorkWingman handles that wall.

The three hard guardrails

These are the entire point of this feature — every piece of code described below exists to enforce them, and every test in AccountFormFillerTests, ApplicationRunServiceTests, and VaultServiceTests asserts them directly rather than inferring them from silence.

  1. WorkWingman never creates the account. It never clicks a "Create account" / "Sign up" submit control. It only ever locates that control (to prove the guard is checking something real) and logs that it is deliberately not clicked.
  2. WorkWingman never authenticates. It never types a password into an authentication field — not the vault-generated signup password, not an existing stored one. On a first visit it offers a freshly generated password for the user to copy. On a return visit it only ever offers the stored credential to the user's password manager (see KeePassXC handoff) for the user to approve.
  3. WorkWingman never solves a CAPTCHA and never completes email verification. Both require a human; the run stays paused until the user has finished them alongside creating the account.

Everything else — the pause model, the vault provisioning, the pre-fill — exists in service of those three rules.

The pause: RunStatus.AwaitingAccount + AccountPrompt

Modeled directly on the existing JudgementCall pause (src/WorkWingman.Core/Models/ApplicationRun.cs):

public class AccountPrompt
{
    public string Id { get; set; } = Guid.NewGuid().ToString("N");
    public string TenantHost { get; set; } = string.Empty;
    public string SignupUrl { get; set; } = string.Empty;
    public string SuggestedUserName { get; set; } = string.Empty;
    public string VaultEntryName { get; set; } = string.Empty;
    public bool CredentialProvisioned { get; set; }
    public bool WasNewlyGenerated { get; set; }
    public List<string> AvailableToPreFillFields { get; set; } = [];
    public bool Acknowledged { get; set; }
}

public enum RunStatus { NotStarted, Running, Paused, AwaitingJudgement, AwaitingAccount, AwaitingFinalReview, Submitted, Stopped, Failed }

ApplicationRun.PendingAccount carries the prompt, exactly parallel to PendingCall. There are TWO entry points that can raise it — both share the same shape and both are real, not dead code:

1. WorkdayAutomationEngine.SignInAsync (src/WorkWingman.Infrastructure/Automation/WorkdayAutomationEngine.cs) is the actual wiring into the live ATS flow, and it raises TWO distinct pauses depending on whether the vault already has an entry for the tenant:

public async Task<bool> SignInAsync(
    IPage page, ApplicationRun run, string tenantHost, ContactIdentity contact, CancellationToken ct = default)
  • No vault entry yet — the account wall. Checks IVaultService.HasAtsEntryAsync(tenantHost) FIRST, before touching the page at all. If there is no entry, it provisions the credential, raises run.PendingAccount, sets RunStatus.AwaitingAccount, and returns false. This closes what was previously a real guardrail gap: the OLD SignInAsync called vault.CreateAtsEntryAsync + vault.ResolveSecretAsync and then typed the vault-generated password into the sign-in field itself on a tenant's first visit — silently creating the account relationship and authenticating, exactly what this feature exists to prevent. See WorkdayAutomationEngineTests.SignInAsync_NoVaultEntry_RaisesAccountPrompt_PausesRun_NeverTouchesPage_ReturnsFalse.
  • An entry already exists — a return visit. Still never types the stored password into the sign-in field. Fills only the non-secret email, then calls IPasswordManagerHandoff.OfferAsync(vaultEntryName, currentPageUrl) — OFFERING the credential to the user's password manager rather than typing it — and raises the SAME AwaitingAccount pause (reusing the account-wall plumbing) so the user completes the actual sign-in themselves. There is no "auto-signed-in" outcome from this method at all today: both branches return false. See WorkdayAutomationEngineTests.SignInAsync_ExistingEntry_NeverTypesThePassword_OffersHandoffInstead_AndPauses and SignInAsync_ExistingEntry_NoHandoffConfigured_StillPauses_NeverTypesPassword (even with no handoff wired at all, it still pauses rather than falling back to typing the password).

Either branch, the credential is resolved through the same GetOrCreateAtsCredentialAsync(tenantHost, email) call — never by recomputing "workday-<firstDnsLabel>" and calling ResolveSecretAsync(entryName) directly. That distinction matters because KeePassVaultService can disambiguate a new tenant onto an entry name OTHER than that derived short label when a different tenant already holds it (see UniqueEntryNameFor above) — recomputing the short name here would sometimes resolve a different tenant's password, or throw, even though HasAtsEntryAsync had just confirmed this exact tenant's entry exists. See WorkdayAutomationEngineTests.SignInAsync_ExistingEntry_ResolvesCredentialByFullHost_NotByRecomputedShortName.

IPasswordManagerHandoff is injected as an OPTIONAL constructor parameter (IPasswordManagerHandoff? passwordManager = null) so every existing call site that constructs WorkdayAutomationEngine with just (session, vault) keeps compiling — with no handoff configured the return-visit branch still pauses (never silently falls back to typing the password), it just skips the OfferAsync call and logs that no handoff is configured.

2. IApplicationRunService.CheckAccountWallAsync (src/WorkWingman.Infrastructure/Services/ApplicationRunService.cs, exposed via POST /api/runs/{runId}/account/check) is the same decision, reachable directly over the API — useful for a tenant check that doesn't require an open Playwright page (e.g. a pre-flight check before a run even starts), and for tests/tooling. Its logic:

  1. First, checks whether THIS run is already paused on THIS exact tenant and unacknowledged (run.PendingAccount?.TenantHost == tenantHost && !Acknowledged) — if so, returns the existing prompt as-is. This matters because step 2 below is what provisions the vault entry in the first place: without this check, a repeated or retried call for the same tenant would see HasAtsEntryAsync now report true (the first call already created the entry) and fall through to "no pause needed," returning null even though the pause is still active — a caller that treats null as "no account wall, keep going" could bypass the guardrail on a retry.
  2. Otherwise checks IVaultService.HasAtsEntryAsync(tenantHost). If an entry already exists (and it isn't the just-provisioned pause from step 1), nothing happens — the run is not paused, because there is nothing to hand off (see return-visit handoff instead).
  3. Otherwise, reads IntakeProfile.Contact, provisions a credential via IVaultService.GetOrCreateAtsCredentialAsync, builds the AccountPrompt, sets run.PendingAccount, sets run.Status = RunStatus.AwaitingAccount, and logs a Warn-level entry. The run stops here until the user acts.
  4. IApplicationRunService.AcknowledgeAccountAsync(runId, promptId) is the only way out of the pause — called exclusively from explicit user action (the "I created the account & signed in" button). It clears PendingAccount and resumes RunStatus.Running — UNLESS the run has already ended (Stopped/Submitted/Failed), in which case it throws rather than resurrecting it. StopAsync deliberately does not clear PendingAccount when stopping (there's no need to touch every pause field just to stop), so without this guard a delayed or direct acknowledge call using the old prompt id could set an explicitly-stopped run back to Running. See ApplicationRunServiceTests.AcknowledgeAccount_OnAStoppedRun_Throws_NeverResurrectsIt.

See ApplicationRunServiceTests.CheckAccountWall_CalledAgainWhileStillPending_ReturnsSamePrompt_NotNull.

The whole check-provision-pause sequence is atomic per run. A per-runId SemaphoreSlim gate (AccountWallGates) wraps steps 1–4 above. Without it, two overlapping /account/check calls for the SAME new tenant could both pass step 2's HasAtsEntryAsync check before EITHER call reaches step 4's run.PendingAccount assignment — so whichever call's vault check ran second would see the OTHER call's freshly-created entry and incorrectly return null ("no pause needed") even though the run is about to be paused. See ApplicationRunServiceTests.CheckAccountWall_ConcurrentCallsForSameRunAndTenant_NeverFalseNegativeNull (20 concurrent calls, none of which may return a false-negative null, all converging on one prompt).

Terminal runs are never mutated. CheckAccountWallAsync returns null immediately (before the gate, before touching the vault) if run.Status is already Stopped, Submitted, or Failed — mirroring AcknowledgeAccountAsync's own terminal-status guard for the same class of stale-request scenario. Without this, a delayed or retried /account/check arriving after the user stopped the run could still provision a credential and pause it right back into AwaitingAccount, making GetActiveRunAsync treat an explicitly-ended run as active again. See ApplicationRunServiceTests.CheckAccountWall_OnAStoppedRun_ReturnsNull_NeverPausesIt.

The deepest gap: two INDEPENDENT runs at the same tenant. StartRunAsync allows multiple concurrently-active runs (there is no "only one run at a time" rule). CheckAccountWallAsync provisions the vault entry the MOMENT the first pause is raised — well before the user has actually created the account. Naively, a SECOND independent run hitting the same tenant while the first run's pause is still unacknowledged would see HasAtsEntryAsync now report true and be waved straight through, even though nobody has created that account yet. This is fixed with two pieces:

  • The check-provision-pause gate (AccountWallGates) is keyed by tenant host, not run id — two different runs checking the same tenant serialize on the same lock rather than racing past each other.
  • Before trusting HasAtsEntryAsync, CheckAccountWallAsync first looks for ANY other active run whose PendingAccount is unacknowledged for this same tenant; if one exists, the new run joins that same AccountPrompt instance (same Id) and pauses too, rather than treating "a vault entry exists" as "the account is done." A tenant-keyed set, UnacknowledgedlyProvisionedTenants, tracks exactly which tenants have an entry THIS process minted for a pause that hasn't been acknowledged yet — pre-existing/seeded entries, or ones an earlier pause already had acknowledged, are trusted immediately as before.
  • AcknowledgeAccountAsync clears that tenant from UnacknowledgedlyProvisionedTenants and also walks every OTHER active run still pointing at the same (now-acknowledged) prompt, resuming all of them — not just the run the acknowledge call happened to come through.

See ApplicationRunServiceTests.CheckAccountWall_SecondIndependentRun_SameUnacknowledgedTenant_JoinsThePause_NeverWavedThrough (two independent runs join the same pause; acknowledging via one resumes both; a third run after acknowledgement proceeds normally).

Both the "join another run's pause" lookup and the "resume every run on acknowledge" cleanup EXCLUDE terminal runs (Stopped/Submitted/Failed). A run the user stopped while it had a pending, unacknowledged prompt stays in ActiveRuns with that prompt still set (StopAsync deliberately doesn't clear every pause field just to stop) — without excluding it, a later run at the same tenant could join that STALE prompt from a dead run, and acknowledging it could set the stopped run back to Running. See ApplicationRunServiceTests.CheckAccountWall_IgnoresAStoppedRunsStalePendingPrompt_NeverJoinsOrResurrectsIt.

AccountPrompt.AvailableToPreFillFields is honest about what has and hasn't happened. CheckAccountWallAsync is a pure service-layer call — it never opens a browser page — so it cannot claim the non-secret fields were actually typed into the signup form; it only reports which fields IntakeProfile.Contact has values for, i.e. which ones AccountFormFiller would be able to fill once the automation engine has navigated to the signup page. The property name (and the UI copy in live-run.html, "Ready to pre-fill from your profile") was deliberately chosen over an earlier "PreFilledFields" name that implied the fill had already happened — it hadn't, and there is no production code path yet that invokes AccountFormFiller against a live page (see docs/technical/scraper-automation-learnings.md and connections-and-sso.md's "Apply automation is not wired end-to-end" note — the same honesty convention applies here).

The generic Resume cannot bypass this pause, on either side. The frontend disables the header Pause/Resume button while AwaitingAccount (LiveRun.isAccountWall), but a disabled client-side button is not a real guardrail — the loopback API allows any origin, so any other page open on the same machine could call POST /api/runs/{id}/resume directly. ApplicationRunService.ResumeAsync therefore throws InvalidOperationException whenever run.PendingAccount is not null, so the pause can ONLY be cleared through AcknowledgeAccountAsync. See ApplicationRunServiceTests.Resume_WhileAwaitingAccount_Throws_AndNeverClearsThePause.

Per-tenant vault provisioning

IVaultService (src/WorkWingman.Core/Interfaces/IVaultService.cs) gained two members:

Task<ProvisionedAtsCredential> GetOrCreateAtsCredentialAsync(string tenantHost, string userName, CancellationToken ct = default);
Task<bool> HasAtsEntryAsync(string tenantHost, CancellationToken ct = default);

KeePassVaultService.GetOrCreateAtsCredentialAsync (src/WorkWingman.Infrastructure/Vault/KeePassVaultService.cs):

  • Reuses, never regenerates. If an entry already exists for workday-<tenant>, its stored password is returned as-is (WasNewlyGenerated = false) — the password is never rotated silently underneath the user.
  • Matches by the full tenant host, never by the short display-name label alone. The human-friendly entry name is still workday-<firstDnsLabel> (e.g. workday-acme), but FindByTenantHost looks up existing entries by their stored Url (which always carries the complete host), not by re-deriving and comparing that label. Two distinct tenants that happen to share a first DNS label — e.g. acme.wd1.myworkdayjobs.com vs. an unrelated acme.icims.com — must never collide onto the same vault entry, or one tenant's account-wall check could silently report an existing entry (wrongly skipping the pause) and hand back a different tenant's password. If a brand-new tenant's derived label collides with an existing DIFFERENT tenant's entry, UniqueEntryNameFor disambiguates by falling back to the full host (and a numeric suffix if even that repeats) rather than silently overwriting. See VaultServiceTests.GetOrCreateAtsCredential_DistinctTenantsSharingFirstDnsLabel_NeverCollide and HasAtsEntry_DoesNotMatchADifferentTenantSharingTheFirstDnsLabel.
  • Mints a strong, unique password for a new tenant, guaranteeing every character class. GenerateStrongPassword draws 20 characters cryptographically at random (RandomNumberGenerator) from upper/lower letters (minus visually-ambiguous ones), digits, and symbols — but sampling every character independently from the combined alphabet can, rarely, omit an entire class (e.g. no digit at all), which many ATS signup forms reject outright. To guarantee this never happens, one character is drawn from each of the four classes first, the remaining 16 slots are filled from the full combined alphabet, and the whole array is then Fisher-Yates shuffled (also with RandomNumberGenerator) so the guaranteed characters aren't predictably in the first four positions. See VaultServiceTests.GetOrCreateAtsCredential_EveryGeneratedPassword_ContainsAllFourCharacterClasses (20 independently-generated passwords, each asserted to contain an uppercase letter, a lowercase letter, a digit, and a symbol — not "usually," every time).
  • Atomic per tenant. The whole find-existing-or-generate-and-store sequence runs inside a SemaphoreSlim gate (_credentialGate), so two concurrent calls for the SAME brand-new tenant can never both pass the "no existing entry" check, each generate a DIFFERENT password, and race on the final write — which would hand the "losing" caller a password that is no longer the one actually persisted in the vault. See VaultServiceTests.GetOrCreateAtsCredential_ConcurrentCallsForSameNewTenant_AllAgreeOnOnePassword (20 concurrent calls for one new tenant, asserted to converge on exactly one stored password).
  • Returns a ProvisionedAtsCredential (src/WorkWingman.Core/Models/VaultEntry.cs):
public class ProvisionedAtsCredential
{
    public string VaultEntryName { get; set; } = string.Empty;
    public string UserName { get; set; } = string.Empty;
    public string Password { get; set; } = string.Empty;
    public bool WasNewlyGenerated { get; set; }
}

The one deliberate exception to "the vault never returns secrets"

Every other vault surface (ListEntriesAsync, ResolveSecretAsync) enforces "secrets never cross the HTTP API" (see docs/technical/connections-and-sso.md). ProvisionedAtsCredential is the one narrow, explicit exception: when the vault mints a fresh per-tenant password so the user can create an account themselves, the password has to reach the user somehow — there is no other channel. RunsController.GetAccountCredential (GET /api/runs/{runId}/account/credential) returns it, loopback-only, never logged, and used for exactly one purpose: rendering a "Copy" button in the account-wall panel. The app itself never feeds this value into a type/fill call against an authentication field.

Resolves the SPECIFIC run named in the route, not "whichever run is active." IApplicationRunService now exposes GetRunAsync(runId) alongside GetActiveRunAsync() — the credential endpoint calls the former. StartRunAsync does not enforce a single active run, so relying on GetActiveRunAsync() here would 404 a perfectly valid pending account prompt on an older non-terminal run as soon as any newer run became "the" active one. See ApplicationRunServiceTests.GetRun_ByExactId_ReturnsThatRun_EvenWhenNotTheActiveOne.

Gated like the dependency installer — and so is acknowledge. The API's CORS policy allows any origin (Program.cs — needed so both the packaged Electron renderer, origin null/file://, and the dev server on localhost:4200 can call it). That means, without a gate, any web page open on the same machine could hit this loopback endpoint and read the raw password. GetAccountCredential is gated on ApiToken.IsTrusted — the same per-launch token mechanism already used by DependenciesController.Install (src/WorkWingman.Api/Security/ApiToken.cs): the Electron preload injects the token, the frontend's apiTokenInterceptor (frontend/src/app/core/api-token.interceptor.ts) attaches it as X-WorkWingman-Token on this route (and on /api/dependencies/install), and the dev server is trusted via its known localhost:4200 origin instead. ApiSmokeTests.AccountCredential_WithoutTokenOrTrustedOrigin_IsRejected asserts a request with neither is rejected with 401.

The same gate applies to RunsController.AcknowledgeAccount (POST /api/runs/{runId}/account/acknowledge) — the endpoint that clears the pause. Without it, a hostile page could read pendingAccount.id off /api/runs/active (also unauthenticated, also CORS-open) and POST straight to acknowledge, faking the user's "I created the account and signed in" confirmation and defeating the entire guardrail ResumeAsync otherwise enforces. See ApiSmokeTests.AccountAcknowledge_WithoutTokenOrTrustedOrigin_IsRejected.

RunsController.CheckAccountWall is gated too, even though it returns no secret. It still mutates local state — provisioning a vault entry, setting PendingAccount, and pausing the run — so a hostile page could otherwise discover the active run id and POST an arbitrary tenant host here to pause or "poison" the run. See ApiSmokeTests.AccountCheck_WithoutTokenOrTrustedOrigin_IsRejected.

Non-secret pre-fill: AccountFormFiller

src/WorkWingman.Infrastructure/Automation/AccountFormFiller.cs, using selector chains in AccountSelectors.cs (mirrors WorkdaySelectors's data-automation-id-first fallback strategy):

public async Task<AccountFillResult> FillNonSecretFieldsAsync(
    IPage page, ContactIdentity contact, CancellationToken ct = default)

Fills first name, last name, email, phone, and address line 1 from IntakeProfile.Contact — skipping any field the profile hasn't collected, never filling blanks. All five fields share the same !string.IsNullOrWhiteSpace(...) guard before filling — an empty profile field is never written into the page (which would otherwise clear a browser-autofilled or partially-typed value already sitting there) and never reported in FilledFields. See AccountFormFillerTests.FillNonSecretFields_EmptyLegalName_IsSkipped_NotBlankFilled_NotReportedAsFilled. Then it extends the existing never-submit guard pattern (WorkdaySelectors.SubmitButton's XML doc: "the lab NEVER clicks this") to the account form:

  • AccountSelectors.Password is located (result.PasswordFieldLocated) but never filled (result.PasswordFieldFilled is hard-coded false — not inferred from an empty fill list).
  • AccountSelectors.CreateAccountSubmit is located (result.SubmitButtonLocated) but never clicked (result.SubmitButtonClicked is hard-coded false).

AccountFillResult exposes both as explicit booleans rather than letting a caller infer "not filled" from an empty collection — a future change that accidentally started filling the password or clicking submit would flip an assertion immediately instead of silently degrading a guard.

Offline fixture: FakeAccountWallSite

tools/WorkWingman.ScraperLab/FakeAccountWallSite.cs mirrors the existing FakeWorkdaySite / FakeIcimsSite loopback-fixture pattern (see docs/technical/scraper-automation-learnings.md): an HttpListener bound to 127.0.0.1 only, serving a signup form with realistic data-automation-id markers. The "Create Account" button's onclick handler logs "CREATE ACCOUNT SUPPRESSED — no-op, no real account created" to the browser console and returns false — there is no /create-account POST wired to it, and the one no-op route that exists (/create-account) is never called by anything in this repo. AccountFormFillerTests drives a real headless Chromium page against this fixture and asserts the console log for "CREATE ACCOUNT SUPPRESSED" never appears, proving the button truly was never clicked (not just "the test didn't check").

CI note. AccountFormFillerTests is the one place in tests/WorkWingman.Tests that needs a real browser — every other test in that project is fast, offline, and browser-free by design (the full UI E2E suite lives in its own separate tests/WorkWingman.E2E project and CI job). Both CI jobs that run WorkWingman.Tests on a fresh runner — backend AND churn (the determinism job that re-runs the whole suite 5×) — install playwright.ps1 install --with-deps chromium against WorkWingman.Tests's own build output right before running tests, mirroring the step the UI E2E job already runs for WorkWingman.E2E. Missing this in either job would make that job's Chromium launch fail on a runner with no pre-provisioned browser.

KeePassXC handoff (IPasswordManagerHandoff)

On a return visit to a tenant that already has a vault entry, WorkWingman still never types the authentication password. This is not just a documented intent — WorkdayAutomationEngine.SignInAsync (above) actually calls this seam on the return-visit path and pauses regardless of the outcome. IPasswordManagerHandoff (src/WorkWingman.Core/Interfaces/IPasswordManagerHandoff.cs) defines the "offer, don't type" contract:

public interface IPasswordManagerHandoff
{
    string ManagerName { get; }
    Task<bool> IsManagerAvailableAsync(CancellationToken ct = default);
    Task<PasswordManagerHandoffResult> OfferAsync(string vaultEntryName, string siteUrl, CancellationToken ct = default);
}

KeePassXcHandoff (src/WorkWingman.Infrastructure/Vault/KeePassXcHandoff.cs) is a stub: IsManagerAvailableAsync always returns false, and OfferAsync always reports Offered = false with a message telling the user to open the site and use the KeePassXC browser extension themselves. It documents the real wiring plan in its XML doc: KeePassXC's browser integration exposes the keepassxc-proxy protocol over native messaging; a real implementation would associate once, then call a "get-logins" request for the site URL so KeePassXC's own UI prompts the user to approve the autofill — the credential bytes would travel only between the vault and KeePassXC, never through WorkWingman's HTTP API, and WorkWingman would never call any "set-password"/type-into-page API.

This mirrors exactly how connections-and-sso.md documents every other Stub integration: honest about what's wired versus what's a documented seam.

API surface

RunsController (src/WorkWingman.Api/Controllers/RunsController.cs) gained three endpoints:

Route Method Purpose Token-gated?
/api/runs/{runId}/account/check POST { tenantHost } → raises AccountPrompt + pauses if the tenant has no vault entry; returns null otherwise Yes (no secret returned, but state-changing)
/api/runs/{runId}/account/credential GET Reveals the (existing-or-freshly-provisioned) ProvisionedAtsCredential for the pending prompt — the one deliberate secret-over-API exception above Yes
/api/runs/{runId}/account/acknowledge POST { promptId } → the user confirms they created the account and signed in; resumes the run Yes

Frontend

frontend/src/app/features/live-run/live-run.ts + .html render an account-wall panel (.banner.account-wall) when run.pendingAccount is set, modeled on the existing judgement-call banner:

  • Chips list the non-secret fields ready to pre-fill (AccountPrompt.availableToPreFillFields) — copy reads "Ready to pre-fill from your profile," not "pre-filled," since this pause fires before the automation engine ever opens the signup page.
  • The provisioned credential is fetched once per pending prompt (getAccountCredential, never pre-fetched) and rendered as read-only <code> text — never an <input> — with Copy buttons for username and password (frontend/src/app/core/api.service.ts's checkAccountWall / getAccountCredential / acknowledgeAccount). LiveRun tracks which prompt id the loaded credential belongs to (loadedForPromptId), not merely "is one loaded" — if the poll ever surfaces a different pending prompt (a later run's account wall, or a replaced prompt) while a previous tenant's credential is still displayed, it refetches immediately. The stale credential is also cleared the INSTANT a new prompt is detected — not just once the new fetch resolves — so a slow or failed request for the new tenant can never leave the OLD tenant's username/password showing (and copyable) under the new tenant's banner. The subscriber also re-checks loadedForPromptId before applying its OWN response — an old, slow request for a superseded prompt that finally resolves after a newer prompt's (faster) request has already landed must be ignored, not silently swap the displayed credential back to the wrong (stale) tenant's secret. See live-run.spec.ts's refetches the credential when the pending prompt is replaced by a different one, clears the previous credential immediately when a new prompt appears, before the new fetch resolves, and ignores an OLD request that resolves AFTER a newer prompt has already superseded it.
  • Open site ↗ opens AccountPrompt.signupUrl in a new external tab (window.open(url, '_blank', 'noopener,noreferrer')) — the app never navigates its own embedded browser to complete the signup.
  • "I created the account & signed in" is the only control that clears the pause (confirmAccountCreated()acknowledgeAccount).

Tests

  • AccountFormFillerTests (backend, Playwright-driven against FakeAccountWallSite): pre-fill proof, password-never-filled proof, submit-never-clicked proof (via console-log assertion), empty-field-skip proof covering ALL five fields including first/last name (a blank name must never blank an existing page value or be reported as filled — the same rule email/phone/address already followed).
  • FakeAccountWallSiteTests: fixture safety invariants (loopback-only, no-op create-account route, suppressed submit).
  • VaultServiceTests: HasAtsEntryAsync, fresh-password generation (length, alphabet variety, and — via 20 [Theory] cases — every generated password guaranteed to contain all four character classes), reuse-never-regenerate, seeded-tenant reuse keeps the seeded username, distinct tenants sharing a first DNS label never collide onto the same entry or credential.
  • ApplicationRunServiceTests: unknown-tenant raises AccountPrompt + pauses + provisions; known-tenant does not pause; a repeated/retried check for the SAME still-pending tenant returns the same prompt rather than null; reuse-not-regenerate at the service layer; acknowledge clears the pause and resumes; wrong-prompt-id and no-pending-prompt both throw without side effects; the generic ResumeAsync throws (never silently clears) while PendingAccount is set; GetRunAsync resolves a specific non-active run by id and returns null (never throws) for an unknown id; concurrent /account/check calls for the same tenant never produce a false-negative null; a stopped run's stale /account/check is ignored; TWO INDEPENDENT runs at the same unacknowledged tenant join the same shared prompt (never one waved through because the other's pause already provisioned the vault entry), acknowledging via either resumes both, and a stopped run's stale pending prompt is never joined onto or resurrected by a later run's acknowledgement.
  • WorkdayAutomationEngineTests: SignInAsync on a missing vault entry raises AccountPrompt, pauses, NEVER calls CreateAtsEntryAsync/ResolveSecretAsync (the old silent-create-and-type path), and returns false without ever touching the page; an existing-but-just-created credential is correctly reported as not newly generated; the existing-entry path resolves the credential through the full-host lookup, never a recomputed short entry name that could resolve a disambiguated-away different tenant's password; SignupUrl prefers the automation page's actual current URL over a synthesized tenant-root URL, falling back to the tenant root only when no page is available; on a RETURN VISIT (existing entry) the password is never typed — the handoff seam is consulted with the entry name + current page URL and the run still pauses, including when no IPasswordManagerHandoff is configured at all (never falls back to typing).
  • KeePassXcHandoffTests: the stub always reports "not offered," never returns a secret-shaped message.
  • ApiSmokeTests: /account/check, /account/credential, and /account/acknowledge all reject requests without the app token or the trusted dev origin, and pass through (to the next downstream check) when either is present.
  • api-token.interceptor.spec.ts: the interceptor attaches X-WorkWingman-Token on all three gated account routes plus the dependency installer, never on unrelated routes, and never without a token present.
  • Frontend live-run.spec.ts: account-wall banner + paused state, chip rendering, one-fetch-per- prompt credential loading (refetches when the prompt is replaced by a different one, clears the stale value immediately rather than waiting for the new fetch to resolve, retries on the next poll after a transient failure instead of wedging on "Fetching…" forever, ignores an old now-superseded request that resolves out of order after a newer one already landed), no fetch when there's no prompt, no <input> in the credential block, copy actions call navigator.clipboard.writeText with the right value, the generic Resume/Pause header button is disabled and a no-op during the account wall, acknowledge wiring, external-tab-only signup link, newly-generated vs. reused-entry messaging.
  • Plain-language: Per-tenant ATS accounts
  • Connections & SSO — the vault's general secrets boundary
  • Source: src/WorkWingman.Core/Models/ApplicationRun.cs
  • Source: src/WorkWingman.Core/Models/VaultEntry.cs
  • Source: src/WorkWingman.Core/Interfaces/IVaultService.cs
  • Source: src/WorkWingman.Core/Interfaces/IPasswordManagerHandoff.cs
  • Source: src/WorkWingman.Infrastructure/Vault/KeePassVaultService.cs
  • Source: src/WorkWingman.Infrastructure/Vault/KeePassXcHandoff.cs
  • Source: src/WorkWingman.Infrastructure/Automation/AccountFormFiller.cs
  • Source: src/WorkWingman.Infrastructure/Automation/AccountSelectors.cs
  • Source: src/WorkWingman.Infrastructure/Automation/WorkdayAutomationEngine.csSignInAsync is the real wiring point
  • Source: src/WorkWingman.Infrastructure/Services/ApplicationRunService.cs
  • Source: src/WorkWingman.Api/Controllers/RunsController.cs
  • Source: tools/WorkWingman.ScraperLab/FakeAccountWallSite.cs
  • Source: tools/WorkWingman.ScraperLab/LoopRunner.cs — pre-seeds the fixture tenant so SignInAsync takes the return-visit path, then SIMULATES the user's side of the "offer, don't type" hand-off locally (clears PendingAccount, fills the fixture's email/password itself) so the learning loop can still exercise the post-sign-in application-fill flow — the pause itself is proven separately by WorkdayAutomationEngineTests and AccountFormFillerTests/FakeAccountWallSiteTests, not by this loop
  • KeePassXC: https://keepassxc.org · keepassxc-proxy protocol: https://github.com/keepassxreboot/keepassxc-browser