From b708dc584bac7a2e9a2108208999f4d765d76a82 Mon Sep 17 00:00:00 2001 From: RedBotster <258521541+redbotster@users.noreply.github.com> Date: Wed, 9 Sep 2026 05:56:15 -0700 Subject: [PATCH] feat: extra registration fields (DOB, address, phone) typed like the username Real signup forms almost never stop at username and password. Found this testing against two real practice sites (automationexercise.com, demoqa.com): both require a handful of other fields before they will accept a signup at all, and the registration policy had no way to supply them. RegistrationPolicy/RegistrationGrant gain an optional extraFields list, {selector, value} pairs typed in order, right after the username and before the password. They're typed the same way the username already is: plainly, by the bridge, in the same windowed page as the password. That's what keeps them out of the agent's context, the same structural guarantee, not a new one, and not a SecretHandle -- these aren't rotating credentials, so they live in the policy like the username does rather than the vault's secret storage. The whole vault file is still sealed at rest either way. CLI: `1claw-vault allow-signup` gains a repeatable --field =. Tested against a real Chromium and a real form (registration-real.test.ts): asserts the site's own fields actually received the values, not just that the grant carries them. All 267 existing tests still pass. --- README.md | 43 ++++++++++++++++--- packages/browser-bridge/bin/1claw-vault.mjs | 30 +++++++++++-- .../src/drivers/local-vault-file.ts | 9 ++++ packages/browser-bridge/src/drivers/local.ts | 1 + .../browser-bridge/src/registration-engine.ts | 9 ++++ .../src/registration-real.test.ts | 41 +++++++++++++++++- packages/protocol/src/index.ts | 9 ++++ 7 files changed, 129 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index c95b29c..399a064 100644 --- a/README.md +++ b/README.md @@ -306,6 +306,33 @@ The bridge generates the password, types it into a page the agent has never scripted, and only then stores it. The agent gets a binding id back, which it can use for later fills. It never receives the value at any point. +**Real signup forms usually want more than username and password.** A date of +birth, an address, a phone number — required fields that have nothing to do +with the credential itself. `--field =` is repeatable and +covers exactly that: + +```bash +1claw-vault allow-signup ~/.1claw/vault.json \ + --id acme \ + --signup https://acme.example.com/signup \ + --login https://acme.example.com/login \ + --username ada@example.com \ + --hosts acme.example.com \ + --user-sel '#email' --pass-sel '#password' --submit-sel 'button[type=submit]' \ + --success-sel '.dashboard' \ + --field '#mobile=555-0100' \ + --field '#dob=1990-01-01' +``` + +Each field is typed the same way the username already is: plainly, in the +order given, by the bridge, in the same windowed page the password is typed +into. That is what keeps it out of the agent's context — the window, not +whether the value is treated as a secret. None of it is a rotating credential +that needs a `SecretHandle`'s zeroise-on-drop, so it lives in the policy +alongside the username, not in the vault's secret storage. The whole vault +file is still sealed at rest (AES-256-GCM, scrypt from your passphrase) +regardless of which part of it a value sits in. + **Committing is separate from typing, on purpose.** A password stored that the site never accepted produces a binding that will never work, and you find out weeks later when a login fails. So the bridge waits for the success signal you @@ -313,13 +340,15 @@ described — `--success-sel`, or the URL changing — and if it does not see on **cancels rather than commits**. `{"status":"rejected","reason":"no_success_signal"}` means nothing was stored. -**How this is tested.** Four tests drive a real Chromium against a real signup +**How this is tested.** Five tests drive a real Chromium against a real signup form that enforces a password rule and says no when it is not met: one asserts -the credential stored is byte-for-byte the one the site received, one that a -rejected password stores nothing, one that an unrecognisable outcome stores -nothing, and one that logs in afterwards with what was stored. Breaking the -verdict check so it commits regardless turns two of them red; storing a freshly -generated password instead of the typed one turns the other two red. +the credential stored is byte-for-byte the one the site received, one that +extra fields (a phone number, a date of birth) land in the real form fields +the policy names, one that a rejected password stores nothing, one that an +unrecognisable outcome stores nothing, and one that logs in afterwards with +what was stored. Breaking the verdict check so it commits regardless turns two +of them red; storing a freshly generated password instead of the typed one +turns the other two red. Five more go through `startBridge` and the MCP tool itself, because a path exercised only in pieces is a path nobody has run — that is exactly how a @@ -575,7 +604,7 @@ rejecting only cross-site `Origin`s. three times. The drivers get their own suites instead (`mock.test.ts`, `local.test.ts`, 14 tests each), and `saas` is covered end to end against production rather than by unit tests, since it needs a real vault to answer. -- **v0.2** — governed credential registration **(done, local backend)** and governed credential **capture** — a fill in reverse: while logged in, the bridge reads a secret the site generates (an API key, a token) in a windowed page and stores it in the vault, without the agent seeing it **(done, local backend; see `examples/full-flow-capture.mjs`)**; HITL approval queue, TOTP fill, and both on the hosted backend still to come. For HITL the client half is already there — `authorizeFill` may answer `awaiting_approval` and the bridge surfaces `get_approval_status` when a backend declares the `hitl` capability — but all three drivers report `hitl: false`, so nothing produces that answer yet. TOTP has no code at all +- **v0.2** — governed credential registration **(done, local backend)**, including extra required fields beyond username/password — DOB, address, phone — typed the same way the username is, via repeatable `--field =` **(done, local backend)**; and governed credential **capture** — a fill in reverse: while logged in, the bridge reads a secret the site generates (an API key, a token) in a windowed page and stores it in the vault, without the agent seeing it **(done, local backend; see `examples/full-flow-capture.mjs`)**; HITL approval queue, TOTP fill, and both on the hosted backend still to come. For HITL the client half is already there — `authorizeFill` may answer `awaiting_approval` and the bridge surfaces `get_approval_status` when a backend declares the `hitl` capability — but all three drivers report `hitl: false`, so nothing produces that answer yet. TOTP has no code at all - **v0.3** — **cloud-runtime sidecar**: the same flow, unattended, inside a 1Claw runtime container. The bridge already does all of it on a laptop; what it needs is hosting. Two of the three obstacles are packaging (a browser in the image, a diff --git a/packages/browser-bridge/bin/1claw-vault.mjs b/packages/browser-bridge/bin/1claw-vault.mjs index add32bd..1a58a69 100755 --- a/packages/browser-bridge/bin/1claw-vault.mjs +++ b/packages/browser-bridge/bin/1claw-vault.mjs @@ -19,9 +19,17 @@ * 1claw-vault add --id --url --hosts a.com,.b.com * 1claw-vault list * 1claw-vault remove --id - 1claw-vault allow-signup --id --signup --login \\ - --username --hosts a.com --user-sel --pass-sel \\ - [--submit-sel ] [--success-sel ] [--error-sel ] + * 1claw-vault allow-signup --id --signup --login \\ + * --username --hosts a.com --user-sel --pass-sel \\ + * [--submit-sel ] [--success-sel ] [--error-sel ] \\ + * [--field = ...] + * + * --field is repeatable, for the other fields a real signup form asks for -- + * date of birth, an address, a phone number. Each is typed plainly by the + * bridge, the way the username already is: kept out of the agent's context + * because it is typed in the same windowed page as the password, not because + * it is treated as a secret. Order is preserved and matters only in that + * fields fill top to bottom the way you list them. * * The passphrase comes from ONECLAW_BRIDGE_VAULT_PASSPHRASE, or is prompted for * with echo off. Never from a command-line argument: argv is world-readable in @@ -45,6 +53,9 @@ if (process.env.ONECLAW_SUPPRESS_DEPRECATION !== "1") { const [cmd, file, ...rest] = process.argv.slice(2); const flag = (n) => { const i = rest.indexOf(`--${n}`); return i > -1 ? rest[i + 1] : undefined; }; +// Repeatable: --field a --field b -> ["a", "b"]. Order preserved, since +// extraFields are typed in the order given. +const flags = (n) => rest.reduce((acc, v, i) => (v === `--${n}` ? [...acc, rest[i + 1]] : acc), []); function usage(code = 2) { console.error(`usage: @@ -198,6 +209,16 @@ try { console.error("no success signal given; defaulting to --url-changes."); console.error("Pass --success-sel for a selector that appears only once the account exists."); } + // Other required fields the real form has beyond username/password -- + // DOB, address, phone, and the like. Each --field is "="; + // only the first "=" splits, so a value containing one (a URL, say) still + // works. Typed plainly, in the order given, by the bridge -- kept out of + // the agent's context the same way the username already is. + const extraFields = flags("field").map((f) => { + const eq = f.indexOf("="); + if (eq < 1) { console.error(`--field must be "=", got: ${f}`); process.exit(2); } + return { selector: f.slice(0, eq), value: f.slice(eq + 1) }; + }); const pass = await passphrase(); const doc = await load(pass); if (doc.registrations.some((r) => r.id === id)) { console.error(`${id} already allowed`); process.exit(2); } @@ -205,13 +226,14 @@ try { id, signupUrl: signup, loginUrl: login, username, allowedHosts: hosts, usernameSelector: userSel, passwordSelector: passSel, ...(flag("submit-sel") ? { submitSelector: flag("submit-sel") } : {}), + ...(extraFields.length ? { extraFields } : {}), success: { ...(successSel ? { selector: successSel } : { urlChanges: true }), ...(errorSel ? { errorSelector: errorSel } : {}), }, }); await save(doc, pass); - console.error(`allowed signup for ${id} as ${username}`); + console.error(`allowed signup for ${id} as ${username}${extraFields.length ? ` (+${extraFields.length} extra field${extraFields.length === 1 ? "" : "s"})` : ""}`); } else if (cmd === "allow-capture") { // Authorising an agent to capture a secret the site generates (an API key, // a token). Everything it could otherwise choose is fixed here: the page, diff --git a/packages/browser-bridge/src/drivers/local-vault-file.ts b/packages/browser-bridge/src/drivers/local-vault-file.ts index 6871221..44efb30 100644 --- a/packages/browser-bridge/src/drivers/local-vault-file.ts +++ b/packages/browser-bridge/src/drivers/local-vault-file.ts @@ -49,6 +49,15 @@ export type RegistrationPolicy = { readonly usernameSelector: string; readonly passwordSelector: string; readonly submitSelector?: string; + /** + * Other fields the real signup form requires beyond username and password -- + * date of birth, an address, a phone number, and the like. Each is typed + * plainly by the bridge, the same way the username is: never handed to the + * agent, never a SecretHandle, but kept out of the agent's context by the + * same windowed page the password is typed in. Filled in order, before the + * password. + */ + readonly extraFields?: readonly { readonly selector: string; readonly value: string }[]; readonly success: { readonly urlChanges?: boolean; readonly selector?: string; diff --git a/packages/browser-bridge/src/drivers/local.ts b/packages/browser-bridge/src/drivers/local.ts index cabb1b5..ba19e4e 100644 --- a/packages/browser-bridge/src/drivers/local.ts +++ b/packages/browser-bridge/src/drivers/local.ts @@ -283,6 +283,7 @@ export class LocalVaultDriver implements VaultBackend { usernameSelector: policy.usernameSelector, passwordSelector: policy.passwordSelector, ...(policy.submitSelector ? { submitSelector: policy.submitSelector } : {}), + ...(policy.extraFields?.length ? { extraFields: policy.extraFields } : {}), success: policy.success, }; } diff --git a/packages/browser-bridge/src/registration-engine.ts b/packages/browser-bridge/src/registration-engine.ts index 9744f5c..03e0b98 100644 --- a/packages/browser-bridge/src/registration-engine.ts +++ b/packages/browser-bridge/src/registration-engine.ts @@ -84,6 +84,15 @@ export class RegistrationEngine { const before = await this.#url(sessionId); await this.#type(sessionId, grant.usernameSelector, grant.username); + // Other required fields the real form has -- DOB, address, phone, and + // the like. Typed the same way the username is: plainly, by the bridge, + // in this same windowed page, so an agent that could observe the + // password could observe these too, which is exactly what the window + // prevents. In order, before the password, matching a typical signup + // form's own top-to-bottom layout. + for (const field of grant.extraFields ?? []) { + await this.#type(sessionId, field.selector, field.value); + } handle = await takeSecret(grant.registrationId); // `use()` inside typeSecret has already zeroed the buffer; dropping the // reference stops `finally` from disposing an inert handle again. diff --git a/packages/browser-bridge/src/registration-real.test.ts b/packages/browser-bridge/src/registration-real.test.ts index 75b910a..0a87623 100644 --- a/packages/browser-bridge/src/registration-real.test.ts +++ b/packages/browser-bridge/src/registration-real.test.ts @@ -48,7 +48,7 @@ const siteAccepts = (pw: string) => pw.length >= 12 && /[^A-Za-z0-9]/.test(pw); let server: Server; let origin = ""; /** What the site actually received, so we can compare it with what was stored. */ -let received: { username?: string; password?: string } = {}; +let received: { username?: string; password?: string; mobile?: string; dob?: string } = {}; beforeAll(async () => { server = createServer((req, res) => { @@ -59,6 +59,8 @@ beforeAll(async () => { ${url.searchParams.get("error") ? '

Password too weak

' : ""}
+ +
`); @@ -70,7 +72,10 @@ beforeAll(async () => { req.on("end", () => { const form = new URLSearchParams(body); const password = form.get("password") ?? ""; - received = { username: form.get("email") ?? "", password }; + received = { + username: form.get("email") ?? "", password, + mobile: form.get("mobile") ?? "", dob: form.get("dob") ?? "", + }; // A real site rejects and re-renders; it does not silently succeed. const to = siteAccepts(password) ? "/welcome" : "/signup?error=1"; res.writeHead(302, { location: to }); @@ -153,6 +158,38 @@ describe.skipIf(!HAVE_CHROME)("registering against a real signup form", () => { } }, 120_000); + it("types extra fields a real form requires beyond username and password", async () => { + received = {}; + const { driver, transport, engine } = await setup( + policy({ + extraFields: [ + { selector: "#mobile", value: "555-0100" }, + { selector: "#dob", value: "1990-01-01" }, + ], + }), + ); + try { + const grant = await driver.beginRegistration({ siteId: "acme" } as never); + if (grant.kind !== "registration_grant") throw new Error("expected a grant"); + expect(grant.extraFields).toEqual([ + { selector: "#mobile", value: "555-0100" }, + { selector: "#dob", value: "1990-01-01" }, + ]); + + const outcome = await engine.register(grant as never); + expect(outcome).toMatchObject({ status: "registered", bindingId: "acme" }); + + // The real form actually received both -- not just accepted by the + // grant's shape, but typed into the real fields of a real page. + expect(received.mobile).toBe("555-0100"); + expect(received.dob).toBe("1990-01-01"); + expect(received.username).toBe("ada@example.com"); + expect(received.password).toBeTruthy(); + } finally { + await transport.close(); + } + }, 120_000); + it("stores nothing when the site rejects the password", async () => { received = {}; // A policy demanding a password this site will refuse: no symbols, and diff --git a/packages/protocol/src/index.ts b/packages/protocol/src/index.ts index c09c065..f930c57 100644 --- a/packages/protocol/src/index.ts +++ b/packages/protocol/src/index.ts @@ -181,6 +181,15 @@ export type RegistrationGrant = { readonly usernameSelector: string; readonly passwordSelector: string; readonly submitSelector?: string; + /** + * Other fields a real signup form requires beyond username and password -- + * date of birth, a mailing address, a phone number, and the like. Typed the + * same way the username is: plain values from the policy, never secrets in + * the SecretHandle sense, but typed by the bridge in the same windowed page + * as the password, so an agent that could observe one could observe all of + * them. Filled in the order listed, before the password. + */ + readonly extraFields?: readonly { readonly selector: string; readonly value: string }[]; /** How the bridge decides the site accepted the password. */ readonly success: RegistrationSuccess; };