diff --git a/README.md b/README.md index d7042489..eb863c84 100644 --- a/README.md +++ b/README.md @@ -348,6 +348,11 @@ bearer credential: share it only with that user. Readiness means required values populated, not that login succeeded. `fill` requires an already-open page and never navigates or submits it. Optional `page_url` selects the exact page; cards require it. Do not automatically retry failed/unknown fills or fall back to aliases. +Once ready, use `fill` for ordinary web forms. When the item advertises `webmcp_invoke`, use +`items webmcp invoke` instead to bind credential fields to existing `null` inputs of a live +WebMCP tool; the tool may submit or have other side effects, so never retry an uncertain +outcome (see [Invoke WebMCP tools with vault fields](#invoke-webmcp-tools-with-vault-fields)). +1Password credentials use their own advertised `1pw_*` operations instead. Create specs list `fields` as an ordered array. Each entry carries a stable `name` (letters, digits, and underscores, starting with a letter) that keys values, updates, @@ -399,6 +404,7 @@ cannot switch projects. | `kernel vaults items list ` | List item keys, types, providers, status, and required actions | | `kernel vaults items get ` | Inspect state/actions/returned AgentCard aliases and copyable operation commands; `--wait 0..60`, `--expand payment_methods`, `--open` | | `kernel vaults items invoke ` | GET the item, then POST an advertised operation; `authorize --open` opens a returned HTTPS action; `prepare_checkout --params ''` prepares an unused AgentCard card for Square Pay; `fill --params ''` fills checkout or login fields; `collect --open` opens a credential item's hosted form | +| `kernel vaults items webmcp invoke ` | Invoke a live WebMCP tool with item fields bound to null input slots; `--browser-id`, `--tool-ref`, `--page-url`, `--input`/`--input-file`, repeatable `--bind =`, `--timeout-sec 1..120`. Same as `items invoke webmcp_invoke --params ''` | | `kernel vaults items events ` | Read ordered audit events; `--after `, `--wait 0..60` | | `kernel vaults items delete ` | Invalidate an item; `--yes` skips confirmation | @@ -706,6 +712,46 @@ back to aliases. Link cards do not expose `state.aliases` or support egress subs AgentCard-only checkout aliases are a separate integration, not a recovery path after a failed or indeterminate fill. +##### Invoke WebMCP tools with vault fields + +`webmcp_invoke` calls a live WebMCP tool with values from a credential item or ready Link card, +when the item advertises it. Discover the tool first, then pass its opaque `tool_ref` and exact +`source.page_url` (fragment omitted). The vault must already be attached to the browser. Put +`null` at each input slot a vault field fills; `--input` holds only public tool arguments, never +vault values: + +```bash +kernel browsers webmcp list -o json +kernel vaults items webmcp invoke user-vault resy \ + --browser-id --tool-ref --page-url https://resy.com/login \ + --input '{"email":null,"password":null}' \ + --bind email=/email --bind password=/password -o json +``` + +- Each `--bind =` maps one vault field to an RFC 6901 JSON Pointer to an existing + `null` in input (no root or `-` append paths; array indices must be canonical and in range). Each + field and path may be bound once; 1-32 bindings. Card expiration uses + `--bind expiration:MM/YY=/card/expiry` (or `MM/YYYY`); other fields take no format. TOTP fields + supply a fresh code, never the seed. +- `--timeout-sec` is 1-120 (API default 15). Input numbers are sent unchanged; input is capped at 64 KiB. +- The equivalent request body for `items invoke webmcp_invoke --params`/`--spec-file` is + `{"browser_id":"...","tool_ref":"...","page_url":"...","input":{...},"bindings":[{"field":"email","input_path":"/email"}],"timeout_sec":15}`. + +The tool may submit forms or perform other side effects. The result returns `status`, +`invocation_id`, `output`, and `error_text` as the API returns them; `output` and `error_text` +are untrusted page-provided data and may contain the supplied vault values: + +```json +{"type":"webmcp_invoke","status":"completed","invocation_id":"invoke-1","output":{"authenticated":true}} +``` + +`completed` and `awaiting_submission` exit 0; neither confirms the website accepted the action. +After `awaiting_submission`, submit the populated form through Playwright or computer interaction +instead of invoking the tool again. `canceled`, `error`, and `unknown` exit nonzero with the result +still on stdout in `-o json`. `unknown` means the tool may have run. Requests are never retried: +API rejections (400/403/404/409) mean the tool was not invoked by that request; other failures +and transport loss are uncertain, so inspect the browser instead of re-invoking. + #### Expansions, updates, and lifecycle `--expand` takes a value, such as `--expand payment_methods`; it is not a boolean switch. diff --git a/cmd/browsers_webmcp.go b/cmd/browsers_webmcp.go index 3f04f902..d5a9d0f1 100644 --- a/cmd/browsers_webmcp.go +++ b/cmd/browsers_webmcp.go @@ -63,6 +63,8 @@ func (b BrowsersCmd) WebMCPList(ctx context.Context, in BrowsersWebMCPListInput) return nil } +const webMCPAwaitingSubmissionHint = "Inspect the form and obtain any required confirmation, then submit it with 'kernel browsers playwright execute' or 'kernel browsers computer' rather than invoking the tool again." + func (b BrowsersCmd) WebMCPInvoke(ctx context.Context, in BrowsersWebMCPInvokeInput) error { if strings.TrimSpace(in.ToolRef) == "" { return fmt.Errorf("missing --tool-ref value") @@ -94,7 +96,7 @@ func (b BrowsersCmd) WebMCPInvoke(ctx context.Context, in BrowsersWebMCPInvokeIn case kernel.InvocationResultStatusAwaitingSubmission: // A populated form is a result, not a failure. Re-invoking would refill the // same fields, so point the caller at submitting the form they already have. - pterm.Warning.Printfln("WebMCP invocation %s: awaiting_submission — populated a form without submitting it. Inspect the form and obtain any required confirmation, then submit it with 'kernel browsers playwright execute' or 'kernel browsers computer' rather than invoking the tool again.", res.InvocationID) + pterm.Warning.Printfln("WebMCP invocation %s: awaiting_submission — populated a form without submitting it. %s", res.InvocationID, webMCPAwaitingSubmissionHint) default: return fmt.Errorf("WebMCP invocation %s: %s: %s", res.InvocationID, res.Status, res.ErrorText) } diff --git a/cmd/vaults.go b/cmd/vaults.go index 45442d85..59c4e35f 100644 --- a/cmd/vaults.go +++ b/cmd/vaults.go @@ -233,6 +233,9 @@ func (c VaultsCmd) Invoke(ctx context.Context, vault, key, operation string, par if operation == "fill" && (params == nil || params.Fill == nil || open) { return fmt.Errorf("fill requires --params and does not support --open") } + if operation == "webmcp_invoke" && (params == nil || params.WebMCP == nil || open) { + return fmt.Errorf("webmcp_invoke requires --params and does not support --open") + } if operation == "prepare_checkout" && (params == nil || params.Checkout == nil) { return fmt.Errorf("prepare_checkout requires checkout parameters") } @@ -241,7 +244,7 @@ func (c VaultsCmd) Invoke(ctx context.Context, vault, key, operation string, par } item, err := c.vaults.Items.Get(ctx, key, kernel.VaultItemGetParams{IDOrName: vault}, option.WithMaxRetries(0)) if err != nil { - if operation == "fill" || operation == "1pw_fill" { + if operation == "fill" || operation == "1pw_fill" || operation == "webmcp_invoke" { return vaultFillLookupError(err, operation) } return util.CleanedUpSdkError{Err: err} @@ -278,6 +281,9 @@ func (c VaultsCmd) Invoke(ctx context.Context, vault, key, operation string, par if operation == "1pw_fill" { return c.onePasswordFill(ctx, vault, key, params.OnePassword, output) } + if operation == "webmcp_invoke" { + return c.webMCPInvoke(ctx, vault, key, params.WebMCP, output) + } request := kernel.VaultItemPerformOperationParams{IDOrName: vault} if params != nil && params.OnePassword != nil { request = *params.OnePassword diff --git a/cmd/vaults_commands.go b/cmd/vaults_commands.go index 5b2afb6f..43c99312 100644 --- a/cmd/vaults_commands.go +++ b/cmd/vaults_commands.go @@ -59,14 +59,18 @@ Use wallet and card item types for credit cards and payment checkout instead. ` + vaultCredentialPathsHelp + ` -Kernel-hosted credential flow (fill never submits website forms): +Kernel-hosted credential flow (fill never submits website forms; WebMCP tools may): 1. Create a vault per end user and create a browser with --vault . 2. Navigate to a sensitive form and define its fields in natural top-to-bottom order with credentials create --spec-file; that array order controls the user-facing collection form. 3. Present the returned collection URL to the user. Poll items get --wait 60 for ready. -4. Use items invoke fill --spec-file with browser_id and field selectors. +4. Once ready, for ordinary web forms use items invoke fill --spec-file with + browser_id and field selectors (writes fields without submitting). When the item + advertises webmcp_invoke, items webmcp invoke instead binds credential fields to existing + null inputs of a live WebMCP tool (the tool may submit or have side effects). + Never automatically retry either; inspect the browser after an uncertain outcome. Use credentials update --version for edits, or items invoke collect to reopen the form. Credential values belong in protected files/stdin, never command-line arguments. -See credentials --help and items invoke --help for examples. +See credentials --help, items invoke --help, and items webmcp invoke --help for examples. 1Password credential flow: reuse or connect the owner's account with credentials connect, create a 1password credential for the site's login entries, then invoke only the @@ -128,7 +132,7 @@ JSON output preserves returned public fields but omits unknown/opaque provider d addVaultJSONOutputFlag(get) cmd.AddCommand(create, list, get, newVaultDeleteCommand(false)) - items := &cobra.Command{Use: "items", Short: "Inspect readiness and collection URLs, or invoke collect/fill", Long: "Use get --wait 60 to observe readiness and get -o json for schema/version/presence.\nUse invoke collect to obtain a collection URL, or invoke fill --spec-file to fill a browser.\n1Password credentials use the advertised 1pw_* operations instead of collect/fill.\nCreate and edit credentials with vaults credentials; payment items use wallets/cards."} + items := &cobra.Command{Use: "items", Short: "Inspect readiness and collection URLs, or invoke collect/fill/webmcp_invoke", Long: "Use get --wait 60 to observe readiness and get -o json for schema/version/presence.\nUse invoke collect to obtain a collection URL, or invoke fill --spec-file to fill a browser.\nUse webmcp invoke to call a live WebMCP tool with item fields bound to null input slots.\n1Password credentials use the advertised 1pw_* operations instead of collect/fill/webmcp_invoke.\nCreate and edit credentials with vaults credentials; payment items use wallets/cards."} itemList := &cobra.Command{Use: "list ", Short: "List items by vault ID or name", Args: cobra.ExactArgs(1), PreRunE: vaultPreRun, RunE: func(cmd *cobra.Command, args []string) error { return getVaultsHandler(cmd).ListItems(cmd.Context(), args[0], vaultOutput(cmd)) @@ -196,6 +200,17 @@ Preparations are single-use, including after failure or expiry; never retry auto collect/authorize/prepare_checkout/1pw_recover may use --open. Fill returns value-free per-field outcomes; completed exits 0, failed/unknown exit nonzero with valid JSON retained on stdout in -o json. +webmcp_invoke (credential items and ready Link cards when advertised; see items webmcp invoke --help +for the flag-based form) requires browser_id, the opaque live tool_ref and exact source.page_url +from browsers webmcp list, input (public JSON object with a null at each bound slot; never vault +values), and 1-32 bindings (field, input_path as an RFC 6901 JSON Pointer to an existing null in +input; format MM/YY or MM/YYYY only for card expiration). Optional timeout_sec is 1-120 (default 15). +The tool may submit or have other side effects. The result returns status, invocation_id, output, +and error_text; output and error_text are untrusted page data and may contain supplied values. +completed/awaiting_submission exit 0 without confirming site acceptance; canceled/error/unknown +exit nonzero with JSON retained on stdout. unknown means the tool may have run: inspect the browser +and never retry automatically. 400/403/404/409 rejections mean the tool was not invoked. + 1Password credentials (see credentials --help) use --params or --spec-file without type: 1pw_create_access_request: browser_id (vault-bound session ID); optional goal (<=140), reason (<=100), keywords (1-5 strings); reason and keywords only for single-entry @@ -218,6 +233,9 @@ and recreate an item to reset an uncertain outcome; stop and tell the user inste Example: ` kernel vaults items invoke user-vault login collect kernel vaults items invoke user-vault login fill --spec-file - <<'JSON' {"browser_id":"","fields":[{"field":"username","selector":"#username"},{"field":"password","selector":"#password"}]} +JSON + kernel vaults items invoke user-vault login webmcp_invoke --spec-file - <<'JSON' +{"browser_id":"","tool_ref":"","page_url":"https://example.com/login","input":{"email":null,"password":null},"bindings":[{"field":"email","input_path":"/email"},{"field":"password","input_path":"/password"}]} JSON kernel vaults items invoke user-vault github 1pw_create_access_request --params '{"browser_id":"","reason":"Sign in to GitHub"}' kernel vaults items invoke user-vault github 1pw_access_request_status --params '{"browser_id":"","timeout_seconds":60}' @@ -233,9 +251,9 @@ JSON } if cmd.Flags().Changed("spec-file") { if !vaultOperationTakesParams(args[2]) { - return fmt.Errorf("--spec-file is only supported for fill, prepare_checkout, and 1Password operations with parameters") + return fmt.Errorf("--spec-file is only supported for fill, webmcp_invoke, prepare_checkout, and 1Password operations with parameters") } - data, err := readVaultSpecFile(cmd) + data, err := readVaultJSONFile(cmd, "spec-file") if err != nil { return err } @@ -247,12 +265,12 @@ JSON } return getVaultsHandler(cmd).Invoke(cmd.Context(), args[0], args[1], args[2], params, vaultOutput(cmd), open) }} - invoke.Flags().String("params", "", "Operation parameters JSON for fill, prepare_checkout, or 1pw_* (maximum 128 KiB); omit type and credential values; 1pw_update_access_token requires --spec-file") + invoke.Flags().String("params", "", "Operation parameters JSON for fill, webmcp_invoke, prepare_checkout, or 1pw_* (maximum 128 KiB); omit type and credential values; 1pw_update_access_token requires --spec-file") invoke.Flags().String("spec-file", "", "Operation parameters JSON file (use '-' for stdin; maximum 128 KiB)") invoke.MarkFlagsMutuallyExclusive("params", "spec-file") invoke.Flags().Bool("open", false, "Open a returned HTTPS action URL in your browser") addVaultJSONOutputFlag(invoke) - items.AddCommand(itemList, itemGet, itemEvents, invoke, newVaultDeleteCommand(true)) + items.AddCommand(itemList, itemGet, itemEvents, invoke, newVaultWebMCPCommand(), newVaultDeleteCommand(true)) wallets := &cobra.Command{Use: "wallets", Short: "Connect provider wallets and inspect funding methods"} walletCreate := &cobra.Command{Use: "create --provider --spec ''", Short: "Create a wallet and display its connection or enrollment action", Args: cobra.ExactArgs(2), PreRunE: vaultPreRun, diff --git a/cmd/vaults_credential_steering_test.go b/cmd/vaults_credential_steering_test.go index 4ca0b47b..7ce44d8b 100644 --- a/cmd/vaults_credential_steering_test.go +++ b/cmd/vaults_credential_steering_test.go @@ -40,3 +40,40 @@ func TestCredentialHelpSteering(t *testing.T) { assert.Contains(t, cmd.Example, `"label":"Username"`) assert.Contains(t, cmd.Example, `"sensitive":false`) } + +func TestCredentialHelpReadyOperations(t *testing.T) { + create, _, err := newVaultsCommand().Find([]string{"credentials", "create"}) + require.NoError(t, err) + group, _, err := newVaultsCommand().Find([]string{"credentials"}) + require.NoError(t, err) + items, _, err := newVaultsCommand().Find([]string{"items"}) + require.NoError(t, err) + flat := func(s string) string { return strings.Join(strings.Fields(s), " ") } + for name, long := range map[string]string{"vaults": newVaultsCommand().Long, "credentials": group.Long, "credentials create": create.Long} { + t.Run(name, func(t *testing.T) { + text := flat(long) + paths := flat(vaultCredentialPathsHelp) + assert.Contains(t, text, paths) + assert.Contains(t, paths, "Once ready, items invoke fill writes them into ordinary web form fields in a vault-bound browser without submitting") + assert.Contains(t, paths, "When the item advertises webmcp_invoke, items webmcp invoke instead binds credential fields to existing null inputs of a live WebMCP tool; the tool may submit or have other side effects") + assert.Contains(t, paths, "1pw_fill fills and submits through the 1Password extension") + assert.Contains(t, text, "Never automatically retry") + }) + } + text := flat(create.Long) + assert.Contains(t, text, "Ordinary web forms: items invoke fill writes field values without submitting") + assert.Contains(t, text, "Live WebMCP tool: when webmcp_invoke is advertised, run browsers webmcp list, then items webmcp invoke") + assert.Contains(t, text, "Never automatically retry fill or webmcp_invoke") + assert.Contains(t, text, "approves each access request in the 1Password app") + + vaults := flat(newVaultsCommand().Long) + assert.Contains(t, vaults, "for ordinary web forms use items invoke fill --spec-file") + assert.Contains(t, vaults, "When the item advertises webmcp_invoke, items webmcp invoke instead binds credential fields to existing null inputs of a live WebMCP tool (the tool may submit or have side effects)") + assert.Contains(t, vaults, "items webmcp invoke --help") + assert.Contains(t, items.Long, "1Password credentials use the advertised 1pw_* operations instead of collect/fill/webmcp_invoke") + + // The 1Password path keeps its own operations and does not advertise webmcp_invoke. + onePassword := flat(vaultOnePasswordCredentialHelp) + assert.NotContains(t, onePassword, "webmcp") + assert.Contains(t, onePassword, "Never automatically retry an access request, fill, or recovery") +} diff --git a/cmd/vaults_credentials.go b/cmd/vaults_credentials.go index 555521fd..c4a30e51 100644 --- a/cmd/vaults_credentials.go +++ b/cmd/vaults_credentials.go @@ -23,8 +23,11 @@ const vaultCredentialPathsHelp = `Credential vaults have two sign-in paths. Befo choose for them: - Kernel-hosted collection (spec provider "kernel", the default): you define the site's fields, the user types values into a Kernel-hosted form at the returned - collection URL, Kernel stores them encrypted, and items invoke fill writes them - into a vault-bound browser without submitting. + collection URL, and Kernel stores them encrypted. Once ready, items invoke fill + writes them into ordinary web form fields in a vault-bound browser without + submitting. When the item advertises webmcp_invoke, items webmcp invoke instead + binds credential fields to existing null inputs of a live WebMCP tool; the tool + may submit or have other side effects. - 1Password brokered approval (spec provider "1password", preview): requires the login to be in the user's own, non-shared 1Password vault; shared-vault items and passkeys are not supported. State this requirement when asking. The account @@ -100,7 +103,14 @@ Set sensitive:false explicitly for ordinary usernames and email addresses. Reserve sensitive:true for secrets such as passwords, API tokens, and TOTP seeds. Password and totp must be sensitive. Omitted sensitive defaults to true for safety. Omit required values to receive a collection URL to present to the user. -Poll items get --wait 60 until state.status is ready, then use items invoke fill. +Poll items get --wait 60 until state.status is ready. Then choose an advertised operation: +- Ordinary web forms: items invoke fill writes field values without submitting. +- Live WebMCP tool: when webmcp_invoke is advertised, run browsers webmcp list, then + items webmcp invoke with the tool_ref, exact source.page_url, public input containing + null slots, and --bind =. The tool may submit or have other + side effects, and its output may include the supplied values. +Never automatically retry fill or webmcp_invoke; after an uncertain outcome, inspect +the browser and tell the user. Ready means populated, not a successful login. An agent controlling the browser can read filled values. TOTP seeds must not be collected through the hosted form. Get/list output includes definitions, has_value, and explicitly non-sensitive text/email values. @@ -120,7 +130,7 @@ func newVaultCredentialsCommand() *cobra.Command { } cmd := &cobra.Command{Use: name + " --spec-file ", Short: short, Args: cobra.ExactArgs(2), PreRunE: vaultPreRun, Long: vaultCredentialHelp, RunE: func(cmd *cobra.Command, args []string) error { - data, err := readVaultSpecFile(cmd) + data, err := readVaultJSONFile(cmd, "spec-file") if err != nil { return err } @@ -184,16 +194,17 @@ still-pending account unchanged; otherwise it starts a new authorization with a return group } -func readVaultSpecFile(cmd *cobra.Command) ([]byte, error) { - path, _ := cmd.Flags().GetString("spec-file") +// readVaultJSONFile reads a JSON object from the path in flag, or stdin for '-'. +func readVaultJSONFile(cmd *cobra.Command, flag string) ([]byte, error) { + path, _ := cmd.Flags().GetString(flag) if path == "" { - return nil, fmt.Errorf("--spec-file is required (use '-' for stdin)") + return nil, fmt.Errorf("--%s is required (use '-' for stdin)", flag) } var reader io.Reader = cmd.InOrStdin() if path != "-" { f, err := os.Open(path) if err != nil { - return nil, fmt.Errorf("could not open --spec-file") + return nil, fmt.Errorf("could not open --%s", flag) } defer f.Close() reader = f @@ -201,11 +212,11 @@ func readVaultSpecFile(cmd *cobra.Command) ([]byte, error) { const limit = 128 * 1024 data, err := io.ReadAll(io.LimitReader(reader, limit+1)) if err != nil || len(data) > limit { - return nil, fmt.Errorf("could not read --spec-file (maximum 128 KiB)") + return nil, fmt.Errorf("could not read --%s (maximum 128 KiB)", flag) } var object map[string]json.RawMessage if json.Unmarshal(data, &object) != nil || object == nil { - return nil, fmt.Errorf("--spec-file must contain a JSON object") + return nil, fmt.Errorf("--%s must contain a JSON object", flag) } return data, nil } diff --git a/cmd/vaults_credentials_test.go b/cmd/vaults_credentials_test.go index 008ea306..0fcaf9a0 100644 --- a/cmd/vaults_credentials_test.go +++ b/cmd/vaults_credentials_test.go @@ -206,7 +206,7 @@ func TestCredentialDiscoveryAndInvalidInput(t *testing.T) { require.NoError(t, err) cmd.Flags().Set("spec-file", "-") cmd.SetIn(strings.NewReader(`{"fields":{}}`)) - data, err := readVaultSpecFile(cmd) + data, err := readVaultJSONFile(cmd, "spec-file") require.NoError(t, err) assert.JSONEq(t, `{"fields":{}}`, string(data)) } diff --git a/cmd/vaults_fill.go b/cmd/vaults_fill.go index 33181e5b..8763a2ae 100644 --- a/cmd/vaults_fill.go +++ b/cmd/vaults_fill.go @@ -67,29 +67,35 @@ func vaultFillLookupError(err error, operation string) error { return fmt.Errorf("could not retrieve vault item; %s was not invoked", operation) } -func vaultFillRequestError(err error) error { +// vaultOperationRequestError reports a failed operation request. rejected maps the HTTP +// statuses the API returns before the operation runs to their guidance; every other +// failure, including transport loss, gets the uncertain guidance. +func vaultOperationRequestError(err error, operation string, messages map[string]string, rejected map[int]string, uncertain string) error { var apiErr *kernel.Error - if errors.As(err, &apiErr) { - var body struct { - Code string `json:"code"` - } - guidance := vaultFillUncertain - switch apiErr.StatusCode { - case 400, 403, 404, 409: - guidance = "no fields were written by this request; inspect and correct the cause before deciding on a new fill; do not automatically retry" - } - if json.Unmarshal([]byte(apiErr.RawJSON()), &body) == nil { - if message, ok := vaultFillErrorMessages[body.Code]; ok { - return fmt.Errorf("fill failed: %s (HTTP %d): %s; %s", body.Code, apiErr.StatusCode, message, guidance) - } + if !errors.As(err, &apiErr) { + // Do not wrap SDK/transport errors: they can contain request or response data, + // and the root error handler extracts raw SDK error messages through Unwrap. + return fmt.Errorf("%s result unavailable; %s", operation, uncertain) + } + guidance, ok := rejected[apiErr.StatusCode] + if !ok { + guidance = uncertain + } + var body struct { + Code string `json:"code"` + } + if json.Unmarshal([]byte(apiErr.RawJSON()), &body) == nil { + if message, ok := messages[body.Code]; ok { + return fmt.Errorf("%s failed: %s (HTTP %d): %s; %s", operation, body.Code, apiErr.StatusCode, message, guidance) } - return fmt.Errorf("fill request failed (HTTP %d); %s", apiErr.StatusCode, guidance) } - // Do not wrap SDK/transport errors: they can contain request or response data, - // and the root error handler extracts raw SDK error messages through Unwrap. - return fmt.Errorf("fill result unavailable; %s", vaultFillUncertain) + return fmt.Errorf("%s request failed (HTTP %d); %s", operation, apiErr.StatusCode, guidance) } +const vaultFillNotWritten = "no fields were written by this request; inspect and correct the cause before deciding on a new fill; do not automatically retry" + +var vaultFillRejected = map[int]string{400: vaultFillNotWritten, 403: vaultFillNotWritten, 404: vaultFillNotWritten, 409: vaultFillNotWritten} + func (c VaultsCmd) fill(ctx context.Context, vault, key string, params *vaultFillParams, output string) error { request := kernel.FillVaultItemOperationRequestParam{ BrowserID: params.BrowserID, @@ -111,7 +117,7 @@ func (c VaultsCmd) fill(ctx context.Context, vault, key string, params *vaultFil } response, err := c.vaults.Items.PerformOperation(ctx, key, kernel.VaultItemPerformOperationParams{IDOrName: vault, OfFill: &request}, option.WithMaxRetries(0)) if err != nil { - return vaultFillRequestError(err) + return vaultOperationRequestError(err, "fill", vaultFillErrorMessages, vaultFillRejected, vaultFillUncertain) } if response == nil { return fmt.Errorf("empty fill result; %s", vaultFillUncertain) @@ -138,16 +144,16 @@ func (c VaultsCmd) fill(ctx context.Context, vault, key string, params *vaultFil } } if result.Status != "completed" { - return vaultFillOutcomeError{status: result.Status} + return vaultOperationOutcomeError{operation: "fill", status: result.Status} } return nil } // The result has already been printed; retain a nonzero exit without diagnostics. -type vaultFillOutcomeError struct{ status string } +type vaultOperationOutcomeError struct{ operation, status string } -func (e vaultFillOutcomeError) Error() string { return "fill " + e.status } -func (e vaultFillOutcomeError) Silent() bool { return true } +func (e vaultOperationOutcomeError) Error() string { return e.operation + " " + e.status } +func (e vaultOperationOutcomeError) Silent() bool { return true } func parseVaultFillResult(raw json.RawMessage, count int) (*vaultFillResult, error) { invalid := fmt.Errorf("invalid fill result; %s", vaultFillUncertain) @@ -197,6 +203,14 @@ func parseVaultFillResult(raw json.RawMessage, count int) (*vaultFillResult, err const onePasswordFillUncertain = "the form may have been submitted; inspect the browser and do not retry in the same browser" +const onePasswordFillNotSubmitted = "nothing was submitted by this request; inspect the item, browser, and page_url before deciding on a new fill; do not automatically retry" + +// Only these statuses are returned before the extension is invoked. +var onePasswordFillRejected = map[int]string{ + 400: onePasswordFillNotSubmitted, 403: onePasswordFillNotSubmitted, 404: onePasswordFillNotSubmitted, + 409: "nothing was submitted by this request; if several approved entries match this page, pass entry_id from items get -o json; do not automatically retry", +} + var onePasswordFillResultFields = vaultFieldsOf("type status error_code") func (c VaultsCmd) onePasswordFill(ctx context.Context, vault, key string, request *kernel.VaultItemPerformOperationParams, output string) error { @@ -205,28 +219,10 @@ func (c VaultsCmd) onePasswordFill(ctx context.Context, vault, key string, reque response, err := c.vaults.Items.PerformOperation(ctx, key, params, option.WithMaxRetries(0)) if err != nil { var apiErr *kernel.Error - if !errors.As(err, &apiErr) { - return fmt.Errorf("1pw_fill result unavailable; %s", onePasswordFillUncertain) - } - // Only these statuses are returned before the extension is invoked. - guidance := onePasswordFillUncertain - switch apiErr.StatusCode { - case 400, 403, 404: - guidance = "nothing was submitted by this request; inspect the item, browser, and page_url before deciding on a new fill; do not automatically retry" - case 409: - guidance = "nothing was submitted by this request; if several approved entries match this page, pass entry_id from items get -o json; do not automatically retry" - case 503: + if errors.As(err, &apiErr) && apiErr.StatusCode == 503 { return fmt.Errorf("1pw_fill unavailable (HTTP 503): %s", onePasswordUnavailable) } - var body struct { - Code string `json:"code"` - } - if json.Unmarshal([]byte(apiErr.RawJSON()), &body) == nil { - if message, ok := vaultFillErrorMessages[body.Code]; ok { - return fmt.Errorf("1pw_fill failed: %s (HTTP %d): %s; %s", body.Code, apiErr.StatusCode, message, guidance) - } - } - return fmt.Errorf("1pw_fill request failed (HTTP %d); %s", apiErr.StatusCode, guidance) + return vaultOperationRequestError(err, "1pw_fill", vaultFillErrorMessages, onePasswordFillRejected, onePasswordFillUncertain) } if response == nil { return fmt.Errorf("empty 1pw_fill result; %s", onePasswordFillUncertain) @@ -267,7 +263,7 @@ func (c VaultsCmd) onePasswordFill(ctx context.Context, vault, key string, reque } } if result.Status != "fill_submitted" { - return vaultFillOutcomeError{status: result.Status} + return vaultOperationOutcomeError{operation: "1pw_fill", status: result.Status} } return nil } diff --git a/cmd/vaults_operation_params.go b/cmd/vaults_operation_params.go index 283a1898..5a0d5cd2 100644 --- a/cmd/vaults_operation_params.go +++ b/cmd/vaults_operation_params.go @@ -13,6 +13,7 @@ import ( type vaultOperationParams struct { Fill *vaultFillParams + WebMCP *kernel.WebmcpInvokeVaultItemOperationRequestParam Checkout *kernel.VaultCheckoutContextParam // OnePassword is a complete 1pw_* request body; Invoke supplies the vault. OnePassword *kernel.VaultItemPerformOperationParams @@ -24,7 +25,7 @@ func isOnePasswordOperation(operation string) bool { // vaultOperationTakesParams reports whether an operation accepts --params or --spec-file. func vaultOperationTakesParams(operation string) bool { - return operation == "fill" || operation == "prepare_checkout" || (isOnePasswordOperation(operation) && operation != "1pw_recover") + return operation == "fill" || operation == "webmcp_invoke" || operation == "prepare_checkout" || (isOnePasswordOperation(operation) && operation != "1pw_recover") } type vaultFillParams struct { @@ -116,9 +117,19 @@ func parseVaultOperationParams(operation, raw string, paramsSet, openSet bool) ( } return &vaultOperationParams{Checkout: checkout}, nil } + if operation == "webmcp_invoke" { + if !paramsSet { + return nil, fmt.Errorf("webmcp_invoke requires --params or --spec-file with browser_id, tool_ref, page_url, input, and bindings; or use items webmcp invoke") + } + webMCP, err := parseVaultWebMCPParams(raw) + if err != nil { + return nil, err + } + return &vaultOperationParams{WebMCP: webMCP}, nil + } if operation != "fill" { if paramsSet { - return nil, fmt.Errorf("--params is only supported for fill, prepare_checkout, and 1Password operations; authorize takes no parameters") + return nil, fmt.Errorf("--params is only supported for fill, webmcp_invoke, prepare_checkout, and 1Password operations; authorize takes no parameters") } return nil, nil } diff --git a/cmd/vaults_test.go b/cmd/vaults_test.go index 88d8fddf..bf22c210 100644 --- a/cmd/vaults_test.go +++ b/cmd/vaults_test.go @@ -52,7 +52,7 @@ func executeVaultCommand(t *testing.T, client kernel.Client, args ...string) (st } func TestVaultCommandConstruction(t *testing.T) { - for _, path := range []string{"create", "list", "get", "delete", "items list", "items get", "items delete", "items events", "wallets create", "wallets payment-methods", "cards create", "cards update", "items invoke"} { + for _, path := range []string{"create", "list", "get", "delete", "items list", "items get", "items delete", "items events", "wallets create", "wallets payment-methods", "cards create", "cards update", "items invoke", "items webmcp invoke"} { t.Run(path, func(t *testing.T) { cmd, remaining, err := newVaultsCommand().Find(strings.Fields(path)) require.NoError(t, err) diff --git a/cmd/vaults_webmcp.go b/cmd/vaults_webmcp.go new file mode 100644 index 00000000..5c51289e --- /dev/null +++ b/cmd/vaults_webmcp.go @@ -0,0 +1,373 @@ +package cmd + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/url" + "strconv" + "strings" + + kernel "github.com/kernel/kernel-go-sdk" + "github.com/kernel/kernel-go-sdk/option" + "github.com/pterm/pterm" + "github.com/spf13/cobra" +) + +const maxVaultWebMCPInputBytes = 64 * 1024 + +const vaultWebMCPUncertain = "the tool may have run and submitted or changed site state; inspect the browser and do not retry automatically" + +var vaultWebMCPErrorMessages = map[string]string{ + "invalid_request": "check bindings, null input slots, field names, formats, timeout_sec, and that input matches the tool's inputSchema", + "target_changed": "the tool_ref is no longer live or its source.page_url differs; list tools again with browsers webmcp list", + "timeout": "the deadline elapsed before the tool was invoked", + "field_unavailable": "a field has no usable stored value; inspect definitions and presence, and collect missing values", + "conflict": "the item or browser is not ready; inspect readiness, binding, and unresolved prior operations", + "destination_denied": "the tool's page or registering frame is not an authorized destination for this item, or the browser is not bound to the vault", + "not_found": "check the vault, item, browser identifiers, and project", + "execution_failed": "WebMCP invocation failed", +} + +type vaultWebMCPResult struct { + Type string `json:"type"` + Status string `json:"status"` + InvocationID *string `json:"invocation_id,omitempty"` + Output json.RawMessage `json:"output,omitempty"` + ErrorText *string `json:"error_text,omitempty"` +} + +// vaultWebMCPStatuses maps each result status to whether it exits 0 and its human hint. +var vaultWebMCPStatuses = map[string]struct { + ok bool + hint string +}{ + "completed": {true, "The tool reported completion; this does not confirm the website accepted the action. Inspect the page."}, + "awaiting_submission": {true, "The tool populated a form with the supplied values without submitting it. " + webMCPAwaitingSubmissionHint}, + "canceled": {false, "The tool reported cancellation and may have had side effects. Inspect the page before deciding on a new invocation; do not retry automatically."}, + "error": {false, "The tool reported an error and may have had side effects. Inspect the page before deciding on a new invocation; do not retry automatically."}, + "unknown": {false, vaultWebMCPUncertain}, +} + +const vaultWebMCPNotInvoked = "the tool was not invoked by this request; inspect and correct the cause before deciding on a new invocation; do not automatically retry" + +var vaultWebMCPRejected = map[int]string{400: vaultWebMCPNotInvoked, 403: vaultWebMCPNotInvoked, 404: vaultWebMCPNotInvoked, 409: vaultWebMCPNotInvoked} + +func parseVaultWebMCPParams(raw string) (*kernel.WebmcpInvokeVaultItemOperationRequestParam, error) { + object, err := vaultParamsObject(raw, "browser_id tool_ref page_url input bindings timeout_sec") + if err != nil { + return nil, err + } + params := kernel.WebmcpInvokeVaultItemOperationRequestParam{Type: kernel.WebmcpInvokeVaultItemOperationRequestTypeWebmcpInvoke} + for name, target := range map[string]*string{"browser_id": ¶ms.BrowserID, "tool_ref": ¶ms.ToolRef, "page_url": ¶ms.PageURL} { + if value, ok := object[name]; ok && json.Unmarshal(value, target) != nil { + return nil, fmt.Errorf("%s must be a string", name) + } + } + if params.Input, err = decodeVaultWebMCPInput(object["input"]); err != nil { + return nil, err + } + var bindings []json.RawMessage + if json.Unmarshal(object["bindings"], &bindings) != nil { + return nil, fmt.Errorf("bindings must be an array of 1-32 field bindings") + } + for i, rawBinding := range bindings { + binding, err := vaultParamsObject(string(rawBinding), "field input_path format") + if err != nil { + return nil, fmt.Errorf("bindings[%d]: %w", i, err) + } + var b kernel.VaultWebmcpBindingParam + if json.Unmarshal(binding["field"], &b.Field) != nil { + return nil, fmt.Errorf("bindings[%d].field must be a string", i) + } + if json.Unmarshal(binding["input_path"], &b.InputPath) != nil { + return nil, fmt.Errorf("bindings[%d].input_path must be a string", i) + } + if rawFormat, ok := binding["format"]; ok { + var format string + if json.Unmarshal(rawFormat, &format) != nil || format == "" { + return nil, fmt.Errorf("bindings[%d].format must be MM/YY or MM/YYYY", i) + } + b.Format = kernel.Opt(format) + } + params.Bindings = append(params.Bindings, b) + } + if rawTimeout, ok := object["timeout_sec"]; ok { + var timeout *int64 + if json.Unmarshal(rawTimeout, &timeout) != nil || timeout == nil { + return nil, fmt.Errorf("timeout_sec must be an integer between 1 and 120") + } + params.TimeoutSec = kernel.Opt(*timeout) + } + if err := validateVaultWebMCPParams(¶ms, func(i int) string { return fmt.Sprintf("bindings[%d]", i) }); err != nil { + return nil, err + } + return ¶ms, nil +} + +// decodeVaultWebMCPInput accepts only a JSON object without echoing its contents in errors. +// Members stay json.RawMessage: the SDK encodes json.Number as a string, which would change +// numeric arguments. +func decodeVaultWebMCPInput(raw []byte) (map[string]any, error) { + invalid := fmt.Errorf("input must be a JSON object of public tool arguments (maximum 64 KiB)") + dec := json.NewDecoder(bytes.NewReader(raw)) + var members map[string]json.RawMessage + if dec.Decode(&members) != nil || members == nil { + return nil, invalid + } + if _, err := dec.Token(); err != io.EOF { + return nil, invalid + } + input := make(map[string]any, len(members)) + for name, value := range members { + input[name] = value + } + return input, nil +} + +// validateVaultWebMCPParams mirrors the API's webmcp_invoke request rules so mistakes are +// reported locally with specifics; the API remains the source of truth. binding names a +// binding by index in diagnostics. +func validateVaultWebMCPParams(params *kernel.WebmcpInvokeVaultItemOperationRequestParam, binding func(int) string) error { + if strings.TrimSpace(params.BrowserID) == "" { + return fmt.Errorf("browser_id must be a non-empty browser session ID, not a name") + } + if strings.TrimSpace(params.ToolRef) == "" || len(params.ToolRef) > 128 { + return fmt.Errorf("tool_ref must be the opaque tool_ref from browsers webmcp list (at most 128 bytes)") + } + if u, err := url.ParseRequestURI(params.PageURL); err != nil || u.Scheme == "" || strings.Contains(params.PageURL, "#") { + return fmt.Errorf("page_url must be the exact source.page_url from browsers webmcp list (absolute, without a fragment)") + } + if params.TimeoutSec.Valid() && (params.TimeoutSec.Value < 1 || params.TimeoutSec.Value > 120) { + return fmt.Errorf("timeout_sec must be an integer between 1 and 120") + } + invalidInput := fmt.Errorf("input must be a JSON object of public tool arguments (maximum 64 KiB)") + if params.Input == nil { + return invalidInput + } + encoded, err := json.Marshal(params.Input) + if err != nil || len(encoded) > maxVaultWebMCPInputBytes { + return invalidInput + } + dec := json.NewDecoder(bytes.NewReader(encoded)) + dec.UseNumber() + var input any + if dec.Decode(&input) != nil { + return invalidInput + } + if len(params.Bindings) < 1 || len(params.Bindings) > 32 { + return fmt.Errorf("bindings must contain 1-32 field bindings") + } + fields, paths := make(map[string]bool), make(map[string]bool) + for i, b := range params.Bindings { + if strings.TrimSpace(b.Field) == "" || len(b.Field) > 64 { + return fmt.Errorf("%s field must be a non-empty field name of at most 64 bytes", binding(i)) + } + if fields[b.Field] { + return fmt.Errorf("%s field repeats an earlier binding; bind each field once", binding(i)) + } + if paths[b.InputPath] { + return fmt.Errorf("%s input_path repeats an earlier binding; bind each path once", binding(i)) + } + fields[b.Field], paths[b.InputPath] = true, true + if b.Format.Valid() && b.Format.Value != "MM/YY" && b.Format.Value != "MM/YYYY" { + return fmt.Errorf("%s format must be MM/YY or MM/YYYY", binding(i)) + } + if !vaultWebMCPNullSlot(input, b.InputPath) { + return fmt.Errorf("%s input_path must be an RFC 6901 JSON Pointer to an existing null value in input", binding(i)) + } + } + return nil +} + +// vaultWebMCPNullSlot mirrors the API: a binding replaces an existing null and never +// creates a property or array entry. "/" addresses the empty-string key; only the +// root pointer "" is rejected. +func vaultWebMCPNullSlot(input any, path string) bool { + if len(path) < 1 || len(path) > 2048 || path[0] != '/' { + return false + } + parts := strings.Split(path[1:], "/") + if len(parts) > 32 { + return false + } + value := input + for _, part := range parts { + if strings.Contains(strings.NewReplacer("~0", "", "~1", "").Replace(part), "~") { + return false + } + token := strings.NewReplacer("~1", "/", "~0", "~").Replace(part) + switch container := value.(type) { + case map[string]any: + child, ok := container[token] + if !ok { + return false + } + value = child + case []any: + index, err := strconv.Atoi(token) + if err != nil || index < 0 || strconv.Itoa(index) != token || index >= len(container) { + return false + } + value = container[index] + default: + return false + } + } + return value == nil +} + +func (c VaultsCmd) webMCPInvoke(ctx context.Context, vault, key string, request *kernel.WebmcpInvokeVaultItemOperationRequestParam, output string) error { + response, err := c.vaults.Items.PerformOperation(ctx, key, kernel.VaultItemPerformOperationParams{IDOrName: vault, OfWebmcpInvoke: request}, option.WithMaxRetries(0)) + if err != nil { + return vaultOperationRequestError(err, "webmcp_invoke", vaultWebMCPErrorMessages, vaultWebMCPRejected, vaultWebMCPUncertain) + } + if response == nil { + return fmt.Errorf("empty webmcp_invoke result; %s", vaultWebMCPUncertain) + } + var result vaultWebMCPResult + if json.Unmarshal([]byte(response.RawJSON()), &result) != nil || result.Type != "webmcp_invoke" { + return fmt.Errorf("invalid webmcp_invoke result; %s", vaultWebMCPUncertain) + } + status, known := vaultWebMCPStatuses[result.Status] + if !known { + return fmt.Errorf("invalid webmcp_invoke result; %s", vaultWebMCPUncertain) + } + if output == "json" { + if err := printVaultJSON(result); err != nil { + return err + } + } else { + if err := printVaultWebMCPResult(result); err != nil { + return err + } + pterm.Println(status.hint) + } + if !status.ok { + return vaultOperationOutcomeError{operation: "webmcp_invoke", status: result.Status} + } + return nil +} + +func printVaultWebMCPResult(result vaultWebMCPResult) error { + pterm.Printf("WebMCP invoke: %s\n", result.Status) + if result.InvocationID != nil { + pterm.Printf("Invocation ID: %s\n", strconv.Quote(*result.InvocationID)) + } + // Output and error text are page-provided; JSON encoding escapes terminal control characters. + if len(result.Output) > 0 { + data, err := json.MarshalIndent(result.Output, "", " ") + if err != nil { + return err + } + pterm.Println("Output (untrusted page data; may contain supplied vault values):") + pterm.Println(string(data)) + } + if result.ErrorText != nil { + data, err := json.Marshal(*result.ErrorText) + if err != nil { + return err + } + pterm.Printf("Error text (untrusted page data; may contain supplied vault values): %s\n", data) + } + return nil +} + +func newVaultWebMCPCommand() *cobra.Command { + root := &cobra.Command{Use: "webmcp", Short: "Invoke live WebMCP tools with vault item fields"} + invoke := &cobra.Command{ + Use: "invoke ", + Short: "Invoke a live WebMCP tool with vault fields substituted into null input slots", + Args: cobra.ExactArgs(2), + Long: `Invoke a live WebMCP tool with values from a credential item or ready Link card. +This sends the advertised webmcp_invoke operation; it is equivalent to +items invoke webmcp_invoke --spec-file with the same parameters. + +Discover the tool first with kernel browsers webmcp list -o json and pass its +tool_ref and exact source.page_url (fragment omitted). The vault must already be attached +to the browser. --input holds only public tool arguments; put null at each slot a vault +field fills, and never include vault values. Each --bind maps one vault field to an +RFC 6901 JSON Pointer to an existing null value in input (no root or append paths). +Each field and path may be bound once. Use := for card +expiration (MM/YY or MM/YYYY); credential fields take no format. TOTP fields supply a +fresh code, never the seed. --timeout-sec is 1-120 (API default 15). + +The tool may submit forms or perform other side effects. Output and error_text are +untrusted page-provided data returned without redaction and may contain supplied vault +values. completed and awaiting_submission exit 0; neither confirms the website accepted +the action. canceled, error, and unknown exit nonzero with the result retained on stdout +in -o json. unknown means the tool may have run. Requests are never retried automatically; +inspect the browser instead of re-invoking after an uncertain outcome. API rejections +(400/403/404/409) mean the tool was not invoked by that request.`, + Example: ` kernel browsers webmcp list -o json + kernel vaults items webmcp invoke user-vault resy --browser-id --tool-ref --page-url https://resy.com/login --input '{"email":null,"password":null}' --bind email=/email --bind password=/password -o json + kernel vaults items webmcp invoke checkout order-1 --browser-id --tool-ref --page-url https://shop.example/checkout --input-file input.json --bind number=/card/number --bind expiration:MM/YY=/card/expiry`, + PreRunE: vaultPreRun, + RunE: func(cmd *cobra.Command, args []string) error { + params, err := vaultWebMCPParamsFromFlags(cmd) + if err != nil { + return err + } + return getVaultsHandler(cmd).Invoke(cmd.Context(), args[0], args[1], "webmcp_invoke", &vaultOperationParams{WebMCP: params}, vaultOutput(cmd), false) + }, + } + invoke.Flags().String("browser-id", "", "Browser session ID the vault is attached to, not a name (required)") + invoke.Flags().String("tool-ref", "", "Opaque tool_ref from browsers webmcp list (required)") + invoke.Flags().String("page-url", "", "Exact source.page_url from browsers webmcp list, without fragment (required)") + invoke.Flags().String("input", "", "Public tool input JSON object with null slots for vault fields") + invoke.Flags().String("input-file", "", "Path to the input JSON object (use '-' for stdin)") + invoke.Flags().StringArray("bind", nil, "Vault field binding = or :=; repeatable (required)") + invoke.Flags().Int64("timeout-sec", 0, "Tool invocation timeout in seconds, 1-120 (API default 15)") + for _, name := range []string{"browser-id", "tool-ref", "page-url", "bind"} { + _ = invoke.MarkFlagRequired(name) + } + invoke.MarkFlagsOneRequired("input", "input-file") + invoke.MarkFlagsMutuallyExclusive("input", "input-file") + addVaultJSONOutputFlag(invoke) + root.AddCommand(invoke) + return root +} + +func vaultWebMCPParamsFromFlags(cmd *cobra.Command) (*kernel.WebmcpInvokeVaultItemOperationRequestParam, error) { + params := kernel.WebmcpInvokeVaultItemOperationRequestParam{Type: kernel.WebmcpInvokeVaultItemOperationRequestTypeWebmcpInvoke} + params.BrowserID, _ = cmd.Flags().GetString("browser-id") + params.ToolRef, _ = cmd.Flags().GetString("tool-ref") + params.PageURL, _ = cmd.Flags().GetString("page-url") + input, _ := cmd.Flags().GetString("input") + data := []byte(input) + if cmd.Flags().Changed("input-file") { + var err error + if data, err = readVaultJSONFile(cmd, "input-file"); err != nil { + return nil, err + } + } + var err error + if params.Input, err = decodeVaultWebMCPInput(data); err != nil { + return nil, err + } + binds, _ := cmd.Flags().GetStringArray("bind") + for i, bind := range binds { + field, path, ok := strings.Cut(bind, "=") + if !ok || !strings.HasPrefix(path, "/") { + return nil, fmt.Errorf("binding %d (--bind) must be = or :=, e.g. password=/password", i+1) + } + binding := kernel.VaultWebmcpBindingParam{Field: field, InputPath: path} + if name, format, hasFormat := strings.Cut(field, ":"); hasFormat { + if format == "" { + return nil, fmt.Errorf("binding %d (--bind) format must be MM/YY or MM/YYYY", i+1) + } + binding.Field, binding.Format = name, kernel.Opt(format) + } + params.Bindings = append(params.Bindings, binding) + } + if cmd.Flags().Changed("timeout-sec") { + timeout, _ := cmd.Flags().GetInt64("timeout-sec") + params.TimeoutSec = kernel.Opt(timeout) + } + if err := validateVaultWebMCPParams(¶ms, func(i int) string { return fmt.Sprintf("binding %d (--bind)", i+1) }); err != nil { + return nil, err + } + return ¶ms, nil +} diff --git a/cmd/vaults_webmcp_test.go b/cmd/vaults_webmcp_test.go new file mode 100644 index 00000000..ce9b9322 --- /dev/null +++ b/cmd/vaults_webmcp_test.go @@ -0,0 +1,334 @@ +package cmd + +import ( + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const resyCredentialFixture = `{"id":"credential-1","key":"resy","type":"credential","version":1,"spec":{"provider":"kernel","description":"Resy","fields":[{"name":"email","type":"email","sensitive":false},{"name":"password","type":"password","sensitive":true}]},"state":{"status":"ready"},"available_operations":[{"type":"webmcp_invoke","description":"Invoke a live WebMCP tool with selected credential fields."}]}` +const resyWebMCPRequest = `{"type":"webmcp_invoke","browser_id":"browser-session-id","tool_ref":"wmcp_tool_ref_1","page_url":"https://resy.com/login","input":{"email":null,"password":null,"remember":true},"bindings":[{"field":"email","input_path":"/email"},{"field":"password","input_path":"/password"}],"timeout_sec":30}` +const resyWebMCPResult = `{"type":"webmcp_invoke","status":"completed","invocation_id":"invoke-1","output":{"authenticated":true,"user_id":12345678901234567890}}` + +func vaultWebMCPArgs(extra ...string) []string { + return append([]string{"vaults", "items", "webmcp", "invoke", "user-vault", "resy", + "--browser-id", "browser-session-id", "--tool-ref", "wmcp_tool_ref_1", "--page-url", "https://resy.com/login", + "--input", `{"email":null,"password":null,"remember":true}`, + "--bind", "email=/email", "--bind", "password=/password", "--timeout-sec", "30"}, extra...) +} + +func vaultWebMCPClient(t *testing.T, item string, status int, result string, posts *int) func(http.ResponseWriter, *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.Method == http.MethodGet { + assert.Equal(t, "/vaults/user-vault/items/resy", r.URL.Path) + _, _ = io.WriteString(w, item) + return + } + *posts++ + assert.Equal(t, http.MethodPost, r.Method) + assert.Equal(t, "/vaults/user-vault/items/resy/operations", r.URL.Path) + w.WriteHeader(status) + _, _ = io.WriteString(w, result) + } +} + +func TestVaultWebMCPInvokeResyLogin(t *testing.T) { + t.Setenv("KERNEL_PROJECT", "") + specFile := filepath.Join(t.TempDir(), "params.json") + require.NoError(t, os.WriteFile(specFile, []byte(`{"browser_id":"browser-session-id","tool_ref":"wmcp_tool_ref_1","page_url":"https://resy.com/login","input":{"email":null,"password":null,"remember":true},"bindings":[{"field":"email","input_path":"/email"},{"field":"password","input_path":"/password"}],"timeout_sec":30}`), 0o600)) + for name, args := range map[string][]string{ + "flags": vaultWebMCPArgs("-o", "json"), + "spec-file": {"vaults", "items", "invoke", "user-vault", "resy", "webmcp_invoke", "--spec-file", specFile, "-o", "json"}, + } { + t.Run(name, func(t *testing.T) { + posts := 0 + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost { + body, err := io.ReadAll(r.Body) + require.NoError(t, err) + assert.JSONEq(t, resyWebMCPRequest, string(body)) + } + vaultWebMCPClient(t, resyCredentialFixture, 200, resyWebMCPResult, &posts)(w, r) + }) + out, human, err := executeVaultCommand(t, client, args...) + require.NoError(t, err) + assert.Equal(t, 1, posts) + assert.JSONEq(t, resyWebMCPResult, out) + assert.Contains(t, out, "12345678901234567890") + assert.Empty(t, human) + }) + } +} + +func TestVaultWebMCPInvokeHumanOutput(t *testing.T) { + t.Setenv("KERNEL_PROJECT", "") + posts := 0 + client := vaultTestClient(t, vaultWebMCPClient(t, resyCredentialFixture, 200, resyWebMCPResult, &posts)) + out, human, err := executeVaultCommand(t, client, vaultWebMCPArgs()...) + require.NoError(t, err) + assert.Empty(t, out) + assert.Contains(t, human, "WebMCP invoke: completed") + assert.Contains(t, human, `Invocation ID: "invoke-1"`) + assert.Contains(t, human, `"authenticated": true`) + assert.Contains(t, human, "does not confirm the website accepted the action") +} + +func TestVaultWebMCPInvokeInputPreservedAndCardFormat(t *testing.T) { + t.Setenv("KERNEL_PROJECT", "") + card := strings.Replace(readyFillCardFixture, `[{"type":"fill","description":"Fill checkout fields."}]`, `[{"type":"webmcp_invoke","description":"Invoke a live WebMCP tool."}]`, 1) + posts := 0 + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost { + body, err := io.ReadAll(r.Body) + require.NoError(t, err) + assert.JSONEq(t, `{"type":"webmcp_invoke","browser_id":"b","tool_ref":"t","page_url":"https://shop.example/checkout?step=2","input":{"card":{"numbers":[null],"expiry":null},"amount":12345678901234567890,"a/b":null,"t~":null},"bindings":[{"field":"number","input_path":"/card/numbers/0"},{"field":"expiration","format":"MM/YY","input_path":"/card/expiry"},{"field":"cvc","input_path":"/a~1b"},{"field":"billing_name","input_path":"/t~0"}]}`, string(body)) + assert.Contains(t, string(body), "12345678901234567890") + } + vaultWebMCPClient(t, card, 200, `{"type":"webmcp_invoke","status":"awaiting_submission","invocation_id":"invoke-2"}`, &posts)(w, r) + }) + out, human, err := executeVaultCommand(t, client, "vaults", "items", "webmcp", "invoke", "user-vault", "resy", + "--browser-id", "b", "--tool-ref", "t", "--page-url", "https://shop.example/checkout?step=2", + "--input", `{"card":{"numbers":[null],"expiry":null},"amount":12345678901234567890,"a/b":null,"t~":null}`, + "--bind", "number=/card/numbers/0", "--bind", "expiration:MM/YY=/card/expiry", "--bind", "cvc=/a~1b", "--bind", "billing_name=/t~0") + require.NoError(t, err) + assert.Equal(t, 1, posts) + assert.Empty(t, out) + assert.Contains(t, human, "WebMCP invoke: awaiting_submission") + assert.Contains(t, human, "rather than invoking the tool again") +} + +func TestVaultWebMCPInvokeValidation(t *testing.T) { + t.Setenv("KERNEL_PROJECT", "") + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + t.Errorf("unexpected %s request", r.Method) + }) + flag := func(name, value string) []string { + args := vaultWebMCPArgs() + for i, arg := range args { + if arg == name { + args[i+1] = value + return args + } + } + return append(args, name, value) + } + input := func(value string) []string { return flag("--input", value) } + for name, args := range map[string][]string{ + "non-null slot": input(`{"email":"credential-sentinel","password":null}`), + "missing slot": input(`{"password":null}`), + "slot through text": flag("--bind", "password=/password/x"), + "input array": input(`["credential-sentinel"]`), + "input null": input(`null`), + "input trailing": input(`{"email":null,"password":null} {}`), + "input malformed": input(`{"credential-sentinel":`), + "root path": {"vaults", "items", "invoke", "user-vault", "resy", "webmcp_invoke", "--params", `{"browser_id":"b","tool_ref":"t","page_url":"https://resy.com/login","input":{"email":null},"bindings":[{"field":"email","input_path":""}]}`}, + "append path": append(input(`{"email":[],"password":null}`), "--bind", "x=/email/-"), + "noncanonical": append(input(`{"email":null,"password":null,"list":[null,null]}`), "--bind", "x=/list/01"), + "out of range": append(input(`{"email":null,"password":null,"list":[null]}`), "--bind", "x=/list/1"), + "bad escape": append(input(`{"email":null,"password":null,"a~2":null}`), "--bind", "x=/a~2"), + "duplicate field": append(input(`{"email":null,"password":null,"other":null}`), "--bind", "email=/other"), + "duplicate path": append(vaultWebMCPArgs(), "--bind", "username=/email"), + "bind syntax": append(vaultWebMCPArgs(), "--bind", "credential-sentinel"), + "bind no slash": append(vaultWebMCPArgs(), "--bind", "x=email"), + "bad format": append(input(`{"email":null,"password":null,"exp":null}`), "--bind", "expiration:YYYY=/exp"), + "empty format": append(input(`{"email":null,"password":null,"exp":null}`), "--bind", "expiration:=/exp"), + "fragment URL": flag("--page-url", "https://resy.com/login#top"), + "relative URL": flag("--page-url", "/login"), + "blank browser": flag("--browser-id", " "), + "blank tool": flag("--tool-ref", " "), + "long tool": flag("--tool-ref", strings.Repeat("t", 129)), + "timeout zero": flag("--timeout-sec", "0"), + "timeout high": flag("--timeout-sec", "121"), + "input conflict": append(vaultWebMCPArgs(), "--input-file", "-"), + "too large": input(`{"email":null,"password":null,"pad":"` + strings.Repeat("x", 64*1024) + `"}`), + "params open": {"vaults", "items", "invoke", "user-vault", "resy", "webmcp_invoke", "--params", `{}`, "--open"}, + "params missing": {"vaults", "items", "invoke", "user-vault", "resy", "webmcp_invoke"}, + "params type": {"vaults", "items", "invoke", "user-vault", "resy", "webmcp_invoke", "--params", `{"type":"fill","browser_id":"b"}`}, + "params unknown": {"vaults", "items", "invoke", "user-vault", "resy", "webmcp_invoke", "--params", strings.Replace(resyWebMCPRequest, `"type":"webmcp_invoke"`, `"credential-sentinel":1`, 1)}, + "params values": {"vaults", "items", "invoke", "user-vault", "resy", "webmcp_invoke", "--params", `{"browser_id":"b","tool_ref":"t","page_url":"https://resy.com/login","input":{"email":null},"bindings":[{"field":"email","input_path":"/email"}],"values":{"password":"credential-sentinel"}}`}, + "params bindings": {"vaults", "items", "invoke", "user-vault", "resy", "webmcp_invoke", "--params", `{"browser_id":"b","tool_ref":"t","page_url":"https://resy.com/login","input":{"email":null},"bindings":[]}`}, + "params too many": {"vaults", "items", "invoke", "user-vault", "resy", "webmcp_invoke", "--params", `{"browser_id":"b","tool_ref":"t","page_url":"https://resy.com/login","input":{"a":[` + strings.TrimSuffix(strings.Repeat("null,", 33), ",") + `]},"bindings":[` + vaultWebMCPManyBindings(33) + `]}`}, + "params timeout": {"vaults", "items", "invoke", "user-vault", "resy", "webmcp_invoke", "--params", `{"browser_id":"b","tool_ref":"t","page_url":"https://resy.com/login","input":{"email":null},"bindings":[{"field":"email","input_path":"/email"}],"timeout_sec":1.5}`}, + } { + t.Run(name, func(t *testing.T) { + out, human, err := executeVaultCommand(t, client, args...) + require.Error(t, err) + assert.NotContains(t, err.Error(), "credential-sentinel") + assert.Empty(t, out) + assert.Empty(t, human) + }) + } +} + +func vaultWebMCPManyBindings(n int) string { + bindings := make([]string, n) + for i := range bindings { + bindings[i] = fmt.Sprintf(`{"field":"f%d","input_path":"/a/%d"}`, i, i) + } + return strings.Join(bindings, ",") +} + +func TestVaultWebMCPInvokeOutcomes(t *testing.T) { + t.Setenv("KERNEL_PROJECT", "") + for _, tc := range []struct { + name, result, human string + ok bool + }{ + {"error", `{"type":"webmcp_invoke","status":"error","invocation_id":"invoke-3","error_text":"Invalid password for credential-sentinel\u001b[31m"}`, "may have had side effects", false}, + {"canceled", `{"type":"webmcp_invoke","status":"canceled","invocation_id":"invoke-4"}`, "do not retry automatically", false}, + {"unknown", `{"type":"webmcp_invoke","status":"unknown"}`, "the tool may have run", false}, + {"null output", `{"type":"webmcp_invoke","status":"completed","invocation_id":"invoke-5","output":null}`, "Output (untrusted", true}, + } { + t.Run(tc.name, func(t *testing.T) { + posts := 0 + client := vaultTestClient(t, vaultWebMCPClient(t, resyCredentialFixture, 200, tc.result, &posts)) + out, human, err := executeVaultCommand(t, client, vaultWebMCPArgs("-o", "json")...) + assert.Equal(t, 1, posts) + assert.JSONEq(t, tc.result, out) + assert.Empty(t, human) + if tc.ok { + require.NoError(t, err) + } else { + require.Error(t, err) + assert.True(t, err.(interface{ Silent() bool }).Silent()) + } + + posts = 0 + out, human, err = executeVaultCommand(t, client, vaultWebMCPArgs()...) + assert.Equal(t, 1, posts) + assert.Empty(t, out) + assert.Contains(t, human, tc.human) + assert.NotContains(t, human, "\x1b[31m") + assert.Equal(t, tc.ok, err == nil) + }) + } +} + +func TestVaultWebMCPInvokeRequestErrors(t *testing.T) { + t.Setenv("KERNEL_PROJECT", "") + for _, tc := range []struct { + name, body, want string + status int + }{ + {"target changed", `{"code":"target_changed","message":"credential-sentinel"}`, "target_changed (HTTP 400): the tool_ref is no longer live", 400}, + {"destination", `{"code":"destination_denied"}`, "destination_denied (HTTP 403)", 403}, + {"conflict", `{"code":"conflict"}`, "conflict (HTTP 409)", 409}, + {"unrecognized", `{"code":"credential-sentinel"}`, "webmcp_invoke request failed (HTTP 400); the tool was not invoked", 400}, + {"server", `{"code":"execution_failed"}`, "execution_failed (HTTP 500): WebMCP invocation failed; the tool may have run", 500}, + {"gateway", `credential-sentinel`, "webmcp_invoke request failed (HTTP 502); the tool may have run", 502}, + {"invalid result", `{"type":"fill","status":"completed","fields":[]}`, "invalid webmcp_invoke result; the tool may have run", 200}, + {"invalid status", `{"type":"webmcp_invoke","status":"credential-sentinel"}`, "invalid webmcp_invoke result", 200}, + } { + t.Run(tc.name, func(t *testing.T) { + posts := 0 + client := vaultTestClient(t, vaultWebMCPClient(t, resyCredentialFixture, tc.status, tc.body, &posts)) + out, _, err := executeVaultCommand(t, client, vaultWebMCPArgs("-o", "json")...) + require.Error(t, err) + assert.Equal(t, 1, posts, "webmcp_invoke must not be retried") + assert.Contains(t, err.Error(), tc.want) + assert.Contains(t, err.Error(), "retry") + assert.NotContains(t, err.Error(), "credential-sentinel") + assert.Empty(t, out) + }) + } +} + +func TestVaultWebMCPInvokeRequiresAdvertisedOperation(t *testing.T) { + t.Setenv("KERNEL_PROJECT", "") + posts := 0 + item := strings.Replace(resyCredentialFixture, `"webmcp_invoke"`, `"fill"`, 1) + client := vaultTestClient(t, vaultWebMCPClient(t, item, 200, resyWebMCPResult, &posts)) + _, _, err := executeVaultCommand(t, client, vaultWebMCPArgs()...) + require.ErrorContains(t, err, `operation "webmcp_invoke" is not advertised`) + assert.Zero(t, posts) + + client = vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNotFound) + _, _ = io.WriteString(w, `{"code":"not_found"}`) + }) + _, _, err = executeVaultCommand(t, client, vaultWebMCPArgs()...) + require.ErrorContains(t, err, "webmcp_invoke was not invoked") +} + +func TestVaultWebMCPInvokeEmptyKeyPointerAndInputFile(t *testing.T) { + t.Setenv("KERNEL_PROJECT", "") + want := `{"type":"webmcp_invoke","browser_id":"b","tool_ref":"t","page_url":"https://resy.com/login","input":{"":null,"password":null},"bindings":[{"field":"email","input_path":"/"},{"field":"password","input_path":"/password"}]}` + inputFile := filepath.Join(t.TempDir(), "input.json") + require.NoError(t, os.WriteFile(inputFile, []byte(`{"":null,"password":null}`), 0o600)) + for name, source := range map[string][]string{ + "input": {"--input", `{"":null,"password":null}`}, + "input-file": {"--input-file", inputFile}, + "stdin": {"--input-file", "-"}, + "params": nil, + } { + t.Run(name, func(t *testing.T) { + posts := 0 + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost { + body, err := io.ReadAll(r.Body) + require.NoError(t, err) + assert.JSONEq(t, want, string(body)) + } + vaultWebMCPClient(t, resyCredentialFixture, 200, resyWebMCPResult, &posts)(w, r) + }) + args := []string{"vaults", "items", "invoke", "user-vault", "resy", "webmcp_invoke", "--params", strings.Replace(want, `"type":"webmcp_invoke",`, "", 1), "-o", "json"} + if source != nil { + args = append([]string{"vaults", "items", "webmcp", "invoke", "user-vault", "resy", "--browser-id", "b", "--tool-ref", "t", "--page-url", "https://resy.com/login", "--bind", "email=/", "--bind", "password=/password", "-o", "json"}, source...) + } + if name == "stdin" { + restore := os.Stdin + r, w, err := os.Pipe() + require.NoError(t, err) + _, _ = io.WriteString(w, `{"":null,"password":null}`) + require.NoError(t, w.Close()) + os.Stdin = r + t.Cleanup(func() { os.Stdin = restore }) + } + _, _, err := executeVaultCommand(t, client, args...) + require.NoError(t, err) + assert.Equal(t, 1, posts) + }) + } +} + +func TestVaultWebMCPInvokeInputFileErrors(t *testing.T) { + t.Setenv("KERNEL_PROJECT", "") + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { t.Errorf("unexpected %s request", r.Method) }) + dir := t.TempDir() + large := filepath.Join(dir, "large.json") + require.NoError(t, os.WriteFile(large, []byte(`{"pad":"`+strings.Repeat("x", 128*1024)+`"}`), 0o600)) + array := filepath.Join(dir, "array.json") + require.NoError(t, os.WriteFile(array, []byte(`["credential-sentinel"]`), 0o600)) + for path, want := range map[string]string{ + filepath.Join(dir, "missing.json"): "could not open --input-file", + large: "could not read --input-file (maximum 128 KiB)", + array: "--input-file must contain a JSON object", + } { + args := vaultWebMCPArgs() + for i, arg := range args { + if arg == "--input" { + args[i], args[i+1] = "--input-file", path + } + } + _, _, err := executeVaultCommand(t, client, args...) + require.EqualError(t, err, want) + } +} + +func TestVaultOperationOutcomeErrorNamesOperation(t *testing.T) { + t.Setenv("KERNEL_PROJECT", "") + posts := 0 + client := vaultTestClient(t, vaultWebMCPClient(t, resyCredentialFixture, 200, `{"type":"webmcp_invoke","status":"error"}`, &posts)) + _, _, err := executeVaultCommand(t, client, vaultWebMCPArgs("-o", "json")...) + require.EqualError(t, err, "webmcp_invoke error") +} diff --git a/go.mod b/go.mod index 717881ee..2696f88c 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,7 @@ require ( github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.1 github.com/golang-jwt/jwt/v5 v5.2.2 github.com/joho/godotenv v1.5.1 - github.com/kernel/kernel-go-sdk v0.116.0 + github.com/kernel/kernel-go-sdk v0.117.0 github.com/klauspost/compress v1.18.5 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c github.com/pterm/pterm v0.12.80 diff --git a/go.sum b/go.sum index d38ad738..080d8770 100644 --- a/go.sum +++ b/go.sum @@ -66,8 +66,8 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= -github.com/kernel/kernel-go-sdk v0.116.0 h1:NwZwl40sJ11lI8aHYSmMa0b6eXIXoZQVuH6UfPUaZX0= -github.com/kernel/kernel-go-sdk v0.116.0/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= +github.com/kernel/kernel-go-sdk v0.117.0 h1:b6/am7RkJyhadi/98pMHAyuiwyEoDD6smZ4V92pUJ40= +github.com/kernel/kernel-go-sdk v0.117.0/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=