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.
- 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.
- 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.
- 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, raisesrun.PendingAccount, setsRunStatus.AwaitingAccount, and returnsfalse. This closes what was previously a real guardrail gap: the OLDSignInAsynccalledvault.CreateAtsEntryAsync+vault.ResolveSecretAsyncand 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. SeeWorkdayAutomationEngineTests.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 SAMEAwaitingAccountpause (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 returnfalse. SeeWorkdayAutomationEngineTests.SignInAsync_ExistingEntry_NeverTypesThePassword_OffersHandoffInstead_AndPausesandSignInAsync_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:
- 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 seeHasAtsEntryAsyncnow reporttrue(the first call already created the entry) and fall through to "no pause needed," returningnulleven though the pause is still active — a caller that treatsnullas "no account wall, keep going" could bypass the guardrail on a retry. - 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). - Otherwise, reads
IntakeProfile.Contact, provisions a credential viaIVaultService.GetOrCreateAtsCredentialAsync, builds theAccountPrompt, setsrun.PendingAccount, setsrun.Status = RunStatus.AwaitingAccount, and logs aWarn-level entry. The run stops here until the user acts. 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 clearsPendingAccountand resumesRunStatus.Running— UNLESS the run has already ended (Stopped/Submitted/Failed), in which case it throws rather than resurrecting it.StopAsyncdeliberately does not clearPendingAccountwhen 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 toRunning. SeeApplicationRunServiceTests.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,CheckAccountWallAsyncfirst looks for ANY other active run whosePendingAccountis unacknowledged for this same tenant; if one exists, the new run joins that sameAccountPromptinstance (sameId) 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. AcknowledgeAccountAsyncclears that tenant fromUnacknowledgedlyProvisionedTenantsand 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), butFindByTenantHostlooks up existing entries by their storedUrl(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.comvs. an unrelatedacme.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,UniqueEntryNameFordisambiguates by falling back to the full host (and a numeric suffix if even that repeats) rather than silently overwriting. SeeVaultServiceTests.GetOrCreateAtsCredential_DistinctTenantsSharingFirstDnsLabel_NeverCollideandHasAtsEntry_DoesNotMatchADifferentTenantSharingTheFirstDnsLabel. - Mints a strong, unique password for a new tenant, guaranteeing every character class.
GenerateStrongPassworddraws 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 withRandomNumberGenerator) so the guaranteed characters aren't predictably in the first four positions. SeeVaultServiceTests.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
SemaphoreSlimgate (_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. SeeVaultServiceTests.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.Passwordis located (result.PasswordFieldLocated) but never filled (result.PasswordFieldFilledis hard-codedfalse— not inferred from an empty fill list).AccountSelectors.CreateAccountSubmitis located (result.SubmitButtonLocated) but never clicked (result.SubmitButtonClickedis hard-codedfalse).
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'scheckAccountWall/getAccountCredential/acknowledgeAccount).LiveRuntracks 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-checksloadedForPromptIdbefore 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. Seelive-run.spec.ts'srefetches 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, andignores an OLD request that resolves AFTER a newer prompt has already superseded it. - Open site ↗ opens
AccountPrompt.signupUrlin 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 againstFakeAccountWallSite): 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 raisesAccountPrompt+ pauses + provisions; known-tenant does not pause; a repeated/retried check for the SAME still-pending tenant returns the same prompt rather thannull; 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 genericResumeAsyncthrows (never silently clears) whilePendingAccountis set;GetRunAsyncresolves a specific non-active run by id and returns null (never throws) for an unknown id; concurrent/account/checkcalls for the same tenant never produce a false-negativenull; a stopped run's stale/account/checkis 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:SignInAsyncon a missing vault entry raisesAccountPrompt, pauses, NEVER callsCreateAtsEntryAsync/ResolveSecretAsync(the old silent-create-and-type path), and returnsfalsewithout 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;SignupUrlprefers 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 noIPasswordManagerHandoffis 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/acknowledgeall 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 attachesX-WorkWingman-Tokenon 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 callnavigator.clipboard.writeTextwith 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.
Related docs¶
- 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.cs—SignInAsyncis 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 soSignInAsynctakes the return-visit path, then SIMULATES the user's side of the "offer, don't type" hand-off locally (clearsPendingAccount, 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 byWorkdayAutomationEngineTestsandAccountFormFillerTests/FakeAccountWallSiteTests, not by this loop - KeePassXC: https://keepassxc.org · keepassxc-proxy protocol: https://github.com/keepassxreboot/keepassxc-browser