Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 36 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -306,20 +306,49 @@ 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 <selector>=<value>` 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
described — `--success-sel`, or the URL changing — and if it does not see one it
**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
Expand Down Expand Up @@ -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 <selector>=<value>` **(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
Expand Down
30 changes: 26 additions & 4 deletions packages/browser-bridge/bin/1claw-vault.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,17 @@
* 1claw-vault add <file> --id <id> --url <login-url> --hosts a.com,.b.com
* 1claw-vault list <file>
* 1claw-vault remove <file> --id <id>
1claw-vault allow-signup <file> --id <id> --signup <url> --login <url> \\
--username <value> --hosts a.com --user-sel <css> --pass-sel <css> \\
[--submit-sel <css>] [--success-sel <css>] [--error-sel <css>]
* 1claw-vault allow-signup <file> --id <id> --signup <url> --login <url> \\
* --username <value> --hosts a.com --user-sel <css> --pass-sel <css> \\
* [--submit-sel <css>] [--success-sel <css>] [--error-sel <css>] \\
* [--field <css>=<value> ...]
*
* --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
Expand All @@ -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:
Expand Down Expand Up @@ -198,20 +209,31 @@ 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 "<selector>=<value>";
// 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 "<selector>=<value>", 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); }
doc.registrations.push({
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,
Expand Down
9 changes: 9 additions & 0 deletions packages/browser-bridge/src/drivers/local-vault-file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions packages/browser-bridge/src/drivers/local.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
}
Expand Down
9 changes: 9 additions & 0 deletions packages/browser-bridge/src/registration-engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
41 changes: 39 additions & 2 deletions packages/browser-bridge/src/registration-real.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand All @@ -59,6 +59,8 @@ beforeAll(async () => {
${url.searchParams.get("error") ? '<p class="error">Password too weak</p>' : ""}
<form action="/session" method="post">
<input id="email" name="email">
<input id="mobile" name="mobile">
<input id="dob" name="dob">
<input id="password" name="password" type="password">
<button type="submit" id="go">Create</button>
</form></body>`);
Expand All @@ -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 });
Expand Down Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions packages/protocol/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
Expand Down
Loading