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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ Before calling a change done, walk this list and say which entries applied:

The local `inbot serve` service owns the encrypted SQLite database and serves REST under `/v0` and MCP at `/mcp` on the same loopback port. Its scoped outbox runner sends configured Cloudflare mail. Owner browser authentication and static bundle serving are implemented with `serve --web-root <directory>`. The React owner UI connects authentication, mail, API keys, approvals, domains, mail transport settings, and allow/block lists to the local API. Optional remote access provisions a named tunnel and proxied DNS record, supervises a pinned connector, and isolates tunnel traffic on a guarded loopback listener. WebSocket live updates and signed webhook delivery use an encrypted durable event log. OAuth authorization with owner consent and audience-bound tokens is implemented.

**Credential vault.** Credentials belong to an inbox and live in the encrypted database. Agents see names, origins and `inbot://credentials/<id>/<field>` references, never values; every use is authorized again against `credential_*` permissions, inbox scope and the grant's `use_policy` (`approval` by default, or `direct`). The server generates passwords and TOTP codes. Browser fill and capture connect to a CDP endpoint, check the page's main-frame origin against the credential and type with `Input.insertText`; tunnel requests need an owner-allowlisted public HTTPS or WSS host. `inbot run` decrypts references locally with an ephemeral age key and is not a security boundary. Reveal, import and export, request fulfillment and the host allowlist are owner-only, local-only and have no MCP tool. See `apps/server/src/vault/README.md`.
**Credential vault.** Credentials belong to an inbox and live in the encrypted database. Agents see names, origins and `inbot://credentials/<id>/<field>` references, never values; every use is authorized again against `credential_*` permissions, inbox scope and the grant's `use_policy` (`approval` by default, or `direct`). The server generates passwords and TOTP codes. Browser fill and capture connect only to an owner-registered trusted browser (`cdp_endpoints`) or an owner-allowlisted public HTTPS or WSS host, check the page's main-frame origin against the credential and type with `Input.insertText`; tunnel requests need the allowlisted host. Inbot cannot authenticate a CDP peer, so a process that takes over a stopped registered browser's port can impersonate it. `inbot run` decrypts references locally with an ephemeral age key and is not a security boundary. Reveal, import and export, request fulfillment, trusted browsers and the host allowlist are owner-only, local-only and have no MCP tool. See `apps/server/src/vault/README.md`.

**Inbound.** Cloudflare Email Routing delivers each inbox address to an Email Worker. The Worker streams the raw message through age encryption into R2 and queues a pointer. The service pulls the Queue, decrypts, parses, threads, and stores each message, then records the store commit, deletes the object, and acks the pointer. Permanent failures retain ciphertext with durable quarantine metadata. The owner can list, retry buffer objects, or permanently delete items through local REST, CLI, and Settings; acknowledged invalid Queue messages support metadata deletion only. It also reconciles by listing R2, so long outages lose nothing.

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@

- **An inbox for every agent.** Give each agent an address on your own domain, with scoped access through MCP, the CLI or REST. Manage mail, domains and credentials in the owner web app.
- **Your mail, on your machine.** Mail lives in an encrypted local database. Incoming messages wait encrypted in a Cloudflare buffer while your machine is offline.
- **A credential vault for agents.** Agents get references, never values. Inbot generates passwords server-side, types them into the browser over CDP after checking the page's origin, and serves TOTP codes. The owner approves uses, answers credential requests and can import or export Bitwarden JSON.
- **A credential vault for agents.** Agents get references, never values. Inbot generates passwords server-side, types them over CDP into a browser the owner registered, after checking the page's origin, and serves TOTP codes. The owner approves uses, answers credential requests and can import or export Bitwarden JSON.
- **You decide what leaves.** Give agents read-only access, require your approval before sending, or allow direct sends. The service enforces inbox scope, permissions and send policies on every request.

## Install
Expand Down
29 changes: 19 additions & 10 deletions apps/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -609,8 +609,9 @@ values are never printed. After an uncertain browser result, inspect the page
before retrying; these operations are not idempotent.

```sh
inbot credentials fill bot@example.test CREDENTIAL_ID --cdp-endpoint http://127.0.0.1:9222 --selector '#password' --field password --json
inbot credentials capture bot@example.test CREDENTIAL_ID --cdp-endpoint http://127.0.0.1:9222 --selector '#setup-key' --field totp --json
inbot vault settings set --cdp-endpoints http://127.0.0.1:9222 --json
inbot credentials fill bot@example.test CREDENTIAL_ID --idempotency-key browser-fill-1 --cdp-endpoint http://127.0.0.1:9222 --selector '#password' --field password --json
inbot credentials capture bot@example.test CREDENTIAL_ID --idempotency-key browser-capture-1 --cdp-endpoint http://127.0.0.1:9222 --selector '#setup-key' --field totp --json
inbot vault settings show --json
inbot vault settings set --remote-cdp-hosts browser.example.com --remote-cdp-hosts '*.browserbase.com' --json
inbot vault settings set --data '{"remote_cdp_hosts":[]}' --json
Expand All @@ -619,10 +620,15 @@ inbot vault settings set --data '{"remote_cdp_hosts":[]}' --json
The owner `vault settings show`/`set` commands follow the existing grouped
operation pattern (`transport status`/`configure`, `remote status`/`enable`), under
a nested settings group. They map to GET/PUT `/v0/owner/vault/settings` and require
the local owner connection. Set replaces the entire list; an empty list disables
remote fill/capture. Hostnames are lowercase DNS names or wildcard suffixes, with
the local owner connection. Set updates supplied lists and preserves omitted keys.
`--cdp-endpoints` registers trusted browser origins (HTTP/HTTPS/WS/WSS, scheme,
host and optional port only, no trailing slash). Matching uses hostname, effective
port and TLS transport, pairing HTTP/WS and HTTPS/WSS. Loopback requires explicit
registration. Discovery must match the same trusted entry. Empty settings deny
all browser use. `--data '{"cdp_endpoints":[]}'` clears local registrations.
An empty `remote_cdp_hosts` list disables remote fill/capture. Hostnames are lowercase DNS names or wildcard suffixes, with
no schemes, IPs or ports. Wildcards exclude the bare apex. Tunnel browser use
requires HTTPS/WSS and public DNS addresses; local loopback CDP remains usable.
requires HTTPS/WSS and public DNS addresses and ignores local `cdp_endpoints`.
See [browser credential use](../server/src/vault/README.md) for setup and limits.
rejected. Credential read/create/update/delete/TOTP/run permissions and inbox scope
apply independently of mail send policy. Events contain field names only.
Expand Down Expand Up @@ -681,12 +687,13 @@ Inbot. REST/CLI are the supported surfaces for this operation.
Omitted create policy defaults to `approval`; metadata and table output include
its effective value. This is independent of `--send-policy`.

`credentials fill <inbox_id> <credential_id> --idempotency-key <key>` requests
approval when required by the grant. Fill does not generate a key; supply one
`credentials fill <inbox_id> <credential_id> --idempotency-key <key>` and
`credentials capture <inbox_id> <credential_id> --idempotency-key <key>` request
approval when required by the grant. Neither generates a key; supply one
and retry the identical target, selector and field with the same key after owner
approval. The command returns the usual `approval_required` error and exit 4
while pending. Direct grants and owners can fill without a key. Capture,
metadata CRUD, generation and TOTP codes keep their existing permissions.
while pending. Direct grants and owners can fill/capture without a key.
Metadata CRUD, generation and TOTP codes keep their existing permissions.

`inbot run` generates one key when `--idempotency-key` is omitted. On a pending
approval it prints `Waiting for owner approval (request <id>)...` to stderr and
Expand All @@ -707,7 +714,9 @@ Local owner commands use the same owner-only REST endpoints:
- `inbot credentials use-requests reject <request_id>`

List supports `--limit`, `--page-token`, `--ascending` and `--json`; decisions
support `--json`. URL hints are agent claims, not verified browser origins.
support `--json`. List output includes the use kind, browser endpoint origin and
selector. Endpoint credentials and query tokens are excluded from metadata; the
full connect URL remains bound by the fingerprint. URL hints are agent claims, not verified browser origins.
Pending requests expire after 24 hours; approvals permit one use within 15
minutes. Expiry does not change stored state; inspect `expires_at`. Same owner
decisions are idempotent before expiry. Expired/consumed requests need a new key.
Expand Down
87 changes: 86 additions & 1 deletion apps/cli/src/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1909,6 +1909,10 @@ test("parses browser fill/capture flags and local owner vault settings show/set"
);
const overrides = { env: agentEnv(api, key.api_key) };
const browser = await fakeCdp({ value: "cli-capture-secret-g2P" });
await api.request("/v0/owner/vault/settings", {
method: "PUT",
body: { cdp_endpoints: [browser.endpoint] },
});
try {
for (const operation of ["fill", "capture"]) {
const help = await invoke(api, ["credentials", operation, "--help"]);
Expand Down Expand Up @@ -1960,6 +1964,7 @@ test("parses browser fill/capture flags and local owner vault settings show/set"
const initial = await invoke(api, ["vault", "settings", "show", "--json"]);
expect(initial.code).toBe(0);
expect(Schema.decodeUnknownSync(VaultSettings)(JSON.parse(initial.stdout))).toEqual({
cdp_endpoints: [browser.endpoint],
remote_cdp_hosts: [],
});
const saved = await invoke(api, [
Expand All @@ -1974,6 +1979,7 @@ test("parses browser fill/capture flags and local owner vault settings show/set"
]);
expect(saved.code, saved.stderr).toBe(0);
expect(Schema.decodeUnknownSync(VaultSettings)(JSON.parse(saved.stdout))).toEqual({
cdp_endpoints: [browser.endpoint],
remote_cdp_hosts: ["browser.test", "*.browserbase.com"],
});
for (const args of [
Expand All @@ -1993,7 +1999,10 @@ test("parses browser fill/capture flags and local owner vault settings show/set"
"--json",
]);
expect(disabled.code).toBe(0);
expect(JSON.parse(disabled.stdout)).toEqual({ remote_cdp_hosts: [] });
expect(JSON.parse(disabled.stdout)).toEqual({
remote_cdp_hosts: [],
cdp_endpoints: [browser.endpoint],
});
const denied = await agent(api);
expect(
(
Expand Down Expand Up @@ -2175,3 +2184,79 @@ test("requests and fulfills credentials using field JSON from stdin only with ow
"declined",
);
});

test("capture CLI passes approval keys and displays the concrete browser sink in owner use requests", async ({
api,
}) => {
const credential = Schema.decodeUnknownSync(Credential)(
await (
await api.request(`/v0/inboxes/${inboxId}/credentials`, {
method: "POST",
body: { name: "Capture approval", url: "https://example.test" },
})
).json(),
);
const key = await api.run(
api.keys.create({
inbox_ids: [inboxId],
permissions: { credential_fill: true },
send_policy: "read_only",
}),
);
const browser = await fakeCdp({ value: "cli-approved-capture" });
try {
const saved = await invoke(api, [
"vault",
"settings",
"set",
"--cdp-endpoints",
browser.endpoint,
"--json",
]);
expect(saved.code, saved.stderr).toBe(0);
expect(JSON.parse(saved.stdout)).toMatchObject({ cdp_endpoints: [browser.endpoint] });
const args = [
"credentials",
"capture",
inboxId,
credential.credential_id,
"--cdp-endpoint",
`${browser.endpoint}/connect?token=private-token`,
"--selector",
"#password",
"--field",
"password",
"--idempotency-key",
"cli-capture-once",
"--json",
];
const options = { env: agentEnv(api, key.api_key) };
const pending = await invoke(api, args, options);
expect(pending.code).toBe(4);
expect(pending.stderr).toContain("approval_required");
expect(browser.connectionCount).toBe(0);
const listed = await invoke(api, ["credentials", "use-requests", "list"]);
for (const text of ["capture", browser.endpoint, "#password"])
expect(listed.stdout).toContain(text);
expect(listed.stdout).not.toContain("private-token");
const page = await (await api.request("/v0/credential-use-requests")).json();
const { ListCredentialUseRequestsResponse } =
await import("../../../packages/contracts/src/credential-use.js");
const request = Schema.decodeUnknownSync(ListCredentialUseRequestsResponse)(page).requests[0];
if (request === undefined) throw new Error("Expected capture approval request");
expect(
(await invoke(api, ["credentials", "use-requests", "approve", request.request_id])).code,
).toBe(0);
const captured = await invoke(api, args, options);
expect(captured.code, captured.stderr).toBe(0);
expect(Schema.decodeUnknownSync(Credential)(JSON.parse(captured.stdout)).fields).toEqual([
"password",
]);
const count = browser.messages.length;
expect((await invoke(api, args, options)).code).toBe(4);
expect(browser.messages).toHaveLength(count);
expect(captured.stdout).not.toContain("cli-approved-capture");
} finally {
await browser.close();
}
});
11 changes: 6 additions & 5 deletions apps/cli/src/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -581,7 +581,7 @@ export const commands: readonly Command[] = [
{
name: "credentials fill",
description:
"Type a vault field directly into a matching-origin browser page. The totp field types the current code. Approval grants need --idempotency-key; retry unchanged after owner approval. Requires credential_fill.",
"Type a vault field directly into a matching-origin page of an owner-trusted browser. The totp field types the current code. Approval grants need --idempotency-key; retry unchanged after owner approval. Requires credential_fill.",
endpoint: CredentialsApi.endpoints.inboxes_credentials_fill,
idempotent: "manual",
params: credential,
Expand All @@ -590,8 +590,9 @@ export const commands: readonly Command[] = [
{
name: "credentials capture",
description:
"Capture a matching-origin browser field into the vault; returns refs only. Requires credential_fill.",
"Capture a matching-origin field from an owner-trusted browser into the vault; returns refs only. Approval grants need --idempotency-key; retry unchanged after owner approval. Requires credential_fill.",
endpoint: CredentialsApi.endpoints.inboxes_credentials_capture,
idempotent: "manual",
params: credential,
body: credentialTarget,
},
Expand All @@ -610,15 +611,15 @@ export const commands: readonly Command[] = [
},
{
name: "vault settings show",
description: "Inspect remote CDP host settings as local owner.",
description: "Inspect trusted browser and remote CDP host settings as local owner.",
endpoint: CredentialsApi.endpoints.owner_vault_settings,
},
{
name: "vault settings set",
description:
"Replace remote CDP hosts as local owner. Use --data '{\"remote_cdp_hosts\":[]}' to disable remote fill and capture.",
"Update cdp_endpoints (trusted browser origins, e.g. http://127.0.0.1:9222) and remote CDP hosts as local owner. Omitted keys keep their values. Use --data '{\"remote_cdp_hosts\":[]}' to disable remote fill and capture.",
endpoint: CredentialsApi.endpoints.owner_vault_setSettings,
body: { remote_cdp_hosts: "array" },
body: { remote_cdp_hosts: "array", cdp_endpoints: "array" },
},
{
name: "domains list",
Expand Down
23 changes: 21 additions & 2 deletions apps/cli/src/output.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,17 @@ export function humanOutput(value: unknown): string {
"rules",
],
entries: ["entry", "entry_type", "direction", "list_type", "inbox_id", "reason"],
requests: ["request_id", "api_key_name", "inbox_id", "kind", "state", "expires_at", "url_hint"],
requests: [
"request_id",
"api_key_name",
"inbox_id",
"kind",
"state",
"expires_at",
"url_hint",
"browser_endpoint",
"browser_selector",
],
credential_requests: [
"request_id",
"inbox_id",
Expand All @@ -88,7 +98,16 @@ export function humanOutput(value: unknown): string {
const rows = value[key];
if (Array.isArray(rows) && rows.every(record)) {
return (
table(rows, columns) +
table(
key === "requests"
? rows.map((row) => ({
...row,
browser_endpoint: record(row.browser) ? row.browser.endpoint : undefined,
browser_selector: record(row.browser) ? row.browser.selector : undefined,
}))
: rows,
columns,
) +
(typeof value.next_page_token === "string"
? `Next page token: ${cell(value.next_page_token)}\n`
: "")
Expand Down
8 changes: 5 additions & 3 deletions apps/docs/docs/agents/credentials.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ See [MCP tools](/reference/mcp#credentials) for parameters.

## Fill and capture

Both need a CDP endpoint of a browser the Inbot server can reach, such as `http://127.0.0.1:9222` for Chrome started with `--remote-debugging-port=9222`. Pass `inboxId`, `credentialId`, `cdp_endpoint`, `selector` and `field`. Add `url` or `target_id` when the browser has several pages. The credential must list the page's origin.
Both need a CDP endpoint of a browser the Inbot server can reach and the owner has registered as trusted, such as `http://127.0.0.1:9222` for Chrome started with `--remote-debugging-port=9222`. Pass `inboxId`, `credentialId`, `cdp_endpoint`, `selector` and `field`. Add `url` or `target_id` when the browser has several pages. The credential must list the page's origin.

```json
{
Expand All @@ -44,11 +44,13 @@ Both need a CDP endpoint of a browser the Inbot server can reach, such as `http:

Filling `totp` types the current code. Capturing `totp` stores a base32 setup key or `otpauth://` URI shown on the page. Neither returns a value.

The owner must register the browser first. If a call fails with `missing_permission` and a fix that asks for a trusted browser, the endpoint is not registered. Ask your human to have the owner add it under Settings > Trusted browsers (or `inbot vault settings set --cdp-endpoints <origin>`); you cannot do this yourself, and retrying will not help.

## When the owner has to approve

If your key has `use_policy: approval`, a fill needs an idempotency key. The first call returns `approval_required` with a request ID and does nothing else. Tell your human that the owner must approve that request, then retry the identical call with the same key later. Approval covers one successful use within 15 minutes. Do not change the selector or field between attempts, and use a new key if the request expired or was used.
If your key has `use_policy: approval`, a fill or capture needs an idempotency key. The first call returns `approval_required` with a request ID and does nothing else. Tell your human that the owner must approve that request, then retry the identical call with the same key later. Approval covers one successful use within 15 minutes. Do not change the selector or field between attempts, and use a new key if the request expired or was used.

A rejected request returns `missing_permission`. Do not look for another way to use the same credential.
`capture_credential` follows the same rule. A rejected request returns `missing_permission`. Do not look for another way to use the same credential.

## Asking the owner for a credential

Expand Down
Loading