diff --git a/README.md b/README.md index d7042489..1ee3e304 100644 --- a/README.md +++ b/README.md @@ -129,7 +129,7 @@ Commands with JSON output support: - **Proxies**: `create`, `list`, `get`, `update`, `check` - **API Keys**: `create`, `list`, `get`, `update`, `rotate` - **Auth Connections**: `timeline` -- **Vaults**: `create`, `list`, `get`, `credentials create/update`, `items list/get/events/invoke` (including `collect`, `fill`, and `prepare_checkout`), `wallets create/payment-methods`, `cards create/update` (display-safe public fields only) +- **Vaults**: `create`, `list`, `get`, `credentials create/update`, `items list/get/events/invoke` (including `collect`, `fill`, `webmcp_invoke`, and `prepare_checkout`), `wallets create/payment-methods`, `cards create/update` (display-safe public fields only) - **Projects**: `update` - **Org**: `limits get/set` - **Apps**: `list`, `history` @@ -378,6 +378,17 @@ text/email values, definitions, version, and `has_value`. Sensitive values and T seeds are omitted. Credential spec input is capped at 128 KiB; write errors are redacted. +To reuse a managed auth connection's saved credential, create a credential with +provider `managed_auth` and the connection ID from `kernel auth connections list`. +The item stores no values and reads the connection's credential at fill time; it is +created `ready`, `state.fields` lists fill binding names, and `update` returns 409: + +```sh +kernel vaults credentials create user-vault amazon --spec-file - <<'JSON' +{"provider":"managed_auth","connection_id":"","description":"Amazon"} +JSON +``` + Vault names, item keys, and project ownership are immutable. Optionally select a project with `--project ` or `KERNEL_PROJECT`; otherwise, the API resolves the project from your credentials and its defaults (the default project for org-wide credentials, not all projects). @@ -389,7 +400,7 @@ cannot switch projects. | Command | Purpose / flags | | --- | --- | | `kernel vaults create --name ` | Create or retrieve the vault with that immutable name | -| `kernel vaults list` | `--limit 1..100` (default 20), `--offset`; JSON includes `vaults` and optional `next_offset` | +| `kernel vaults list` | `--limit 1..100` (default 20), `--offset`, `--query` (name substring or exact ID); JSON includes `vaults` and optional `next_offset` | | `kernel vaults get ` | Get by ID or name | | `kernel vaults delete ` | Invalidate the vault and all its items; `--yes` skips confirmation | | `kernel vaults wallets create --provider link\|agentcard --spec ''` | Connect/enroll a wallet using its provider's spec; `--open` opens a returned HTTPS action URL | @@ -398,7 +409,7 @@ cannot switch projects. | `kernel vaults cards update --provider link\|agentcard --spec ''` | Update a card spec; pending issuance preserves omitted optional fields, and the API enforces state/provider constraints | | `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 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; `webmcp_invoke --params ''` invokes a WebMCP tool (from `browsers webmcp list`) with vaulted values bound to null input slots by JSON Pointer; `collect --open` opens a credential item's hosted form | | `kernel vaults items events ` | Read ordered audit events; `--after `, `--wait 0..60` | | `kernel vaults items delete ` | Invalidate an item; `--yes` skips confirmation | diff --git a/cmd/credentials.go b/cmd/credentials.go index 3c7d1564..bf982534 100644 --- a/cmd/credentials.go +++ b/cmd/credentials.go @@ -173,10 +173,13 @@ func (c CredentialsCmd) Get(ctx context.Context, in CredentialsGetInput) error { {"Name", cred.Name}, {"Domain", cred.Domain}, {"Has TOTP Secret", hasTOTP}, + } + tableData = append(tableData, credentialTotpRows(cred)...) + tableData = append(tableData, pterm.TableData{ {"SSO Provider", ssoProvider}, {"Created At", util.FormatLocal(cred.CreatedAt)}, {"Updated At", util.FormatLocal(cred.UpdatedAt)}, - } + }...) PrintTableNoPad(tableData, true) return nil @@ -276,8 +279,9 @@ func (c CredentialsCmd) Create(ctx context.Context, in CredentialsCreateInput) e {"Name", cred.Name}, {"Domain", cred.Domain}, {"Has TOTP Secret", hasTOTP}, - {"SSO Provider", ssoProvider}, } + tableData = append(tableData, credentialTotpRows(cred)...) + tableData = append(tableData, []string{"SSO Provider", ssoProvider}) PrintTableNoPad(tableData, true) @@ -289,6 +293,36 @@ func (c CredentialsCmd) Create(ctx context.Context, in CredentialsCreateInput) e return nil } +// normalizeTotpAlgorithm validates a TOTP HMAC algorithm and returns its +// canonical upper-case form (SHA1, SHA256, or SHA512). +func normalizeTotpAlgorithm(algorithm string) (string, error) { + normalized := strings.ToUpper(strings.TrimSpace(algorithm)) + switch normalized { + case "SHA1", "SHA256", "SHA512": + return normalized, nil + default: + return "", fmt.Errorf("invalid --totp-algorithm %q (must be one of SHA1, SHA256, SHA512)", algorithm) + } +} + +// credentialTotpRows returns TOTP metadata rows for credentials with a TOTP secret. +func credentialTotpRows(cred *kernel.Credential) pterm.TableData { + if !cred.HasTotpSecret { + return nil + } + rows := pterm.TableData{} + if cred.TotpAlgorithm != "" { + rows = append(rows, []string{"TOTP Algorithm", string(cred.TotpAlgorithm)}) + } + if cred.TotpDigits > 0 { + rows = append(rows, []string{"TOTP Digits", fmt.Sprintf("%d", cred.TotpDigits)}) + } + if cred.TotpPeriod > 0 { + rows = append(rows, []string{"TOTP Period", fmt.Sprintf("%ds", cred.TotpPeriod)}) + } + return rows +} + func (c CredentialsCmd) Update(ctx context.Context, in CredentialsUpdateInput) error { if err := validateJSONOutput(in.Output); err != nil { return err @@ -427,6 +461,9 @@ Examples: # Create a credential with TOTP for 2FA kernel credentials create --name "my-2fa-site" --domain "example.com" --value "username=myuser" --value "password=mypass" --totp-secret "JBSWY3DPEHPK3PXP" + # Create a credential with custom TOTP parameters + kernel credentials create --name "my-8digit-site" --domain "example.com" --value "username=myuser" --totp-secret "JBSWY3DPEHPK3PXP" --totp-algorithm SHA256 --totp-digits 8 --totp-period 60 + # Create a credential with SSO provider kernel credentials create --name "google-sso" --domain "example.com" --value "email=user@gmail.com" --value "password=mypass" --sso-provider google`, Args: cobra.NoArgs, diff --git a/cmd/credentials_test.go b/cmd/credentials_test.go new file mode 100644 index 00000000..a38477b8 --- /dev/null +++ b/cmd/credentials_test.go @@ -0,0 +1,19 @@ +package cmd + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNormalizeTotpAlgorithm(t *testing.T) { + for input, want := range map[string]string{"SHA1": "SHA1", "sha256": "SHA256", " Sha512 ": "SHA512"} { + got, err := normalizeTotpAlgorithm(input) + require.NoError(t, err) + assert.Equal(t, want, got) + } + + _, err := normalizeTotpAlgorithm("md5") + assert.Error(t, err) +} diff --git a/cmd/logs.go b/cmd/logs.go index 4715eeb1..13fc92bf 100644 --- a/cmd/logs.go +++ b/cmd/logs.go @@ -65,7 +65,12 @@ func runLogs(cmd *cobra.Command, args []string) error { pterm.Info.Println("Showing recent logs (timeout after 3s with no events)") } - stream := client.Invocations.FollowStreaming(cmd.Context(), inv.ID, kernel.InvocationFollowParams{}, option.WithMaxRetries(0)) + // Only forward --since when explicitly set so older invocations still show their full logs + invParams := kernel.InvocationFollowParams{} + if cmd.Flags().Changed("since") { + invParams.Since = kernel.Opt(since) + } + stream := client.Invocations.FollowStreaming(cmd.Context(), inv.ID, invParams, option.WithMaxRetries(0)) if stream.Err() != nil { return fmt.Errorf("failed to follow streaming: %w", stream.Err()) } diff --git a/cmd/offset_pagination_test.go b/cmd/offset_pagination_test.go index fed03c9c..2256aa45 100644 --- a/cmd/offset_pagination_test.go +++ b/cmd/offset_pagination_test.go @@ -72,7 +72,7 @@ func TestOffsetPaginationListCommands(t *testing.T) { case "projects": err = (ProjectsCmd{projects: &client.Projects}).List(context.Background(), ProjectsListInput{Limit: 20, Offset: 20, Output: "json"}) case "vaults": - err = (VaultsCmd{vaults: &client.Vaults}).List(context.Background(), 20, 20, "", "json") + err = (VaultsCmd{vaults: &client.Vaults}).List(context.Background(), 20, 20, "", "", "json") case "vault-provider-configs": err = (VaultProviderConfigsCmd{configs: &client.VaultProviderConfigs}).List(context.Background(), 20, 20, "json") } @@ -87,3 +87,16 @@ func TestOffsetPaginationListCommands(t *testing.T) { } } } + +func TestVaultsListQuery(t *testing.T) { + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "my vault", r.URL.Query().Get("query")) + w.Header().Set("Content-Type", "application/json") + w.Header().Set("X-Has-More", "true") + w.Header().Set("X-Next-Offset", "20") + _, _ = io.WriteString(w, "[]") + }) + setupStdoutCapture(t) + require.NoError(t, (VaultsCmd{vaults: &client.Vaults}).List(context.Background(), 20, 0, "my vault", "", "table")) + assert.Contains(t, outBuf.String(), `--query "my vault"`) +} diff --git a/cmd/vaults.go b/cmd/vaults.go index 45442d85..9525b701 100644 --- a/cmd/vaults.go +++ b/cmd/vaults.go @@ -51,12 +51,16 @@ func (c VaultsCmd) Get(ctx context.Context, vault, output string) error { return printVault(v, output) } -func (c VaultsCmd) List(ctx context.Context, limit, offset int64, project, output string) error { +func (c VaultsCmd) List(ctx context.Context, limit, offset int64, query, project, output string) error { if limit < 1 || limit > 100 || offset < 0 { return fmt.Errorf("--limit must be between 1 and 100; --offset must be non-negative") } var response *http.Response - page, err := c.vaults.List(ctx, kernel.VaultListParams{Limit: kernel.Opt(limit), Offset: kernel.Opt(offset)}, option.WithMaxRetries(0), option.WithResponseInto(&response)) + params := kernel.VaultListParams{Limit: kernel.Opt(limit), Offset: kernel.Opt(offset)} + if query != "" { + params.Query = kernel.Opt(query) + } + page, err := c.vaults.List(ctx, params, option.WithMaxRetries(0), option.WithResponseInto(&response)) if err != nil { return util.CleanedUpSdkError{Err: err} } @@ -88,7 +92,11 @@ func (c VaultsCmd) List(ctx context.Context, limit, offset int64, project, outpu if project != "" { projectFlag = fmt.Sprintf(" --project %q", project) } - pterm.Printf("Next: kernel%s vaults list --limit %d --offset %d\n", projectFlag, limit, pagination.NextOffset) + queryFlag := "" + if query != "" { + queryFlag = fmt.Sprintf(" --query %q", query) + } + pterm.Printf("Next: kernel%s vaults list --limit %d --offset %d%s\n", projectFlag, limit, pagination.NextOffset, queryFlag) } return nil } @@ -236,12 +244,15 @@ func (c VaultsCmd) Invoke(ctx context.Context, vault, key, operation string, par if operation == "prepare_checkout" && (params == nil || params.Checkout == nil) { return fmt.Errorf("prepare_checkout requires checkout parameters") } + if operation == "webmcp_invoke" && (params == nil || params.WebMCP == nil || open) { + return fmt.Errorf("webmcp_invoke requires --params and does not support --open") + } if isOnePasswordOperation(operation) && (params == nil || params.OnePassword == nil) { return fmt.Errorf("%s requires its documented parameters", operation) } 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} @@ -260,7 +271,7 @@ func (c VaultsCmd) Invoke(ctx context.Context, vault, key, operation string, par for _, op := range actions.Operations { if op.Type == operation { available = true - if output != "json" && operation != "fill" { + if output != "json" && operation != "fill" && operation != "webmcp_invoke" { pterm.Info.Println(op.Description) } break @@ -275,6 +286,9 @@ func (c VaultsCmd) Invoke(ctx context.Context, vault, key, operation string, par if operation == "fill" { return c.fill(ctx, vault, key, params.Fill, output) } + if operation == "webmcp_invoke" { + return c.webmcpInvoke(ctx, vault, key, params.WebMCP, output) + } if operation == "1pw_fill" { return c.onePasswordFill(ctx, vault, key, params.OnePassword, output) } diff --git a/cmd/vaults_commands.go b/cmd/vaults_commands.go index 5b2afb6f..e7ba5809 100644 --- a/cmd/vaults_commands.go +++ b/cmd/vaults_commands.go @@ -114,11 +114,13 @@ JSON output preserves returned public fields but omits unknown/opaque provider d RunE: func(cmd *cobra.Command, args []string) error { limit, _ := cmd.Flags().GetInt64("limit") offset, _ := cmd.Flags().GetInt64("offset") + query, _ := cmd.Flags().GetString("query") project, _ := cmd.Flags().GetString("project") - return getVaultsHandler(cmd).List(cmd.Context(), limit, offset, resolveProjectSelection(project), vaultOutput(cmd)) + return getVaultsHandler(cmd).List(cmd.Context(), limit, offset, query, resolveProjectSelection(project), vaultOutput(cmd)) }} list.Flags().Int64("limit", 20, "Maximum vaults to return (1-100)") list.Flags().Int64("offset", 0, "Number of vaults to skip") + list.Flags().String("query", "", "Case-insensitive substring match against vault name; IDs match by exact value") addVaultJSONOutputFlag(list) get := &cobra.Command{Use: "get ", Short: "Get a vault by ID or name", Args: cobra.ExactArgs(1), PreRunE: vaultPreRun, @@ -128,7 +130,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.\n1Password credentials use the advertised 1pw_* operations instead of collect/fill.\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)) @@ -193,6 +195,16 @@ approval and browser Authorised responses are not capture or fulfillment evidenc Use only when advertised for an AgentCard card. Keep the returned approval page open, poll until ready_to_submit, then submit native Pay before preparation.expires_at. Preparations are single-use, including after failure or expiry; never retry automatically. +webmcp_invoke invokes a WebMCP tool with vaulted values. Discover tool_ref, inputSchema, and +the source page with browsers webmcp list. Requires browser_id (vault-bound session ID), +tool_ref, page_url (exact top-level URL from the tool source, fragment omitted), input +(public arguments with a null slot at each binding path; never include vault values), and +1-32 bindings (field, input_path as an RFC 6901 JSON Pointer such as /password, and format +MM/YY or MM/YYYY only for card expiration). Optional timeout_sec is 1-120 (default 15). +The tool may submit or perform other side effects. Output and error_text are untrusted +page data returned without redaction and may include supplied values. completed and +awaiting_submission exit 0; canceled, error, and unknown exit nonzero. Never retry after +unknown; inspect the page instead. 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. @@ -223,6 +235,9 @@ JSON kernel vaults items invoke user-vault github 1pw_access_request_status --params '{"browser_id":"","timeout_seconds":60}' kernel vaults items invoke user-vault github 1pw_fill --params '{"browser_id":"","page_url":"https://github.com/login"}' kernel vaults items invoke user-vault github 1pw_fill --params '{"browser_id":"","page_url":"https://github.com/login","entry_id":""}' + kernel vaults items invoke user-vault login webmcp_invoke --spec-file - <<'JSON' +{"browser_id":"","tool_ref":"","page_url":"https://accounts.example.com/signin","input":{"email":null,"password":null},"bindings":[{"field":"email","input_path":"/email"},{"field":"password","input_path":"/password"}]} +JSON kernel vaults items invoke checkout order-1 fill --params '{"browser_id":"browser-session-id","page_url":"https://shop.example/checkout","fields":[{"field":"number","selector":"#card-number"}]}' -o json`, RunE: func(cmd *cobra.Command, args []string) error { open, _ := cmd.Flags().GetBool("open") @@ -233,7 +248,7 @@ 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, prepare_checkout, webmcp_invoke, and 1Password operations with parameters") } data, err := readVaultSpecFile(cmd) if err != nil { @@ -247,7 +262,7 @@ 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, prepare_checkout, webmcp_invoke, 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") @@ -255,7 +270,7 @@ JSON items.AddCommand(itemList, itemGet, itemEvents, invoke, 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, + walletCreate := &cobra.Command{Use: "create --provider --spec ''", Short: "Create a wallet and display its connection or enrollment action", Args: cobra.ExactArgs(2), PreRunE: vaultPreRun, Long: "Create a wallet at an immutable key and follow the returned provider action.\n" + vaultSpecHelp + vaultWalletSpecHelp, Example: ` kernel vaults wallets create checkout wallet-1 \ --provider link --spec '{ @@ -266,7 +281,10 @@ JSON }' --open kernel vaults wallets create checkout wallet-1 \ - --provider agentcard --spec '{}'`, + --provider agentcard --spec '{}' + + kernel vaults wallets create checkout wallet-2 \ + --provider kernel --spec '{}' --open`, RunE: func(cmd *cobra.Command, args []string) error { spec, err := vaultWalletSpecFromFlags(cmd) if err != nil { @@ -320,13 +338,14 @@ func newVaultCardCommand(update bool) *cobra.Command { if update { use, short = "update", "Update a card spec when the API permits configuration" } - cmd := &cobra.Command{Use: use + " --provider --spec ''", Short: short, Args: cobra.ExactArgs(2), PreRunE: vaultPreRun, - Long: short + `. Neither create nor update authorizes a Link card. + cmd := &cobra.Command{Use: use + " --provider --spec ''", Short: short, Args: cobra.ExactArgs(2), PreRunE: vaultPreRun, + Long: short + `. Neither create nor update authorizes a Link or Kernel card. Requested cards accept a replacement spec. Pending issuance updates preserve omitted optional fields; explicit empty lists clear them. The API restricts fields after authorization starts; wallet/provider bindings cannot change. An uncertain update enters recovery_required and must not be retried. Checkout cards can be edited -between authorizations. Identical creates return existing state without resetting it. +between authorizations. Kernel cards cannot be updated; delete and create a new item. +Identical creates return existing state without resetting it. Never reconfigure the same item to retry a failed, timed-out, rejected, or indeterminate payment. A recovery item that permits abandonment must be deleted after explicit user confirmation before creating a replacement. ` + vaultSpecHelp + vaultCardSpecHelp, @@ -342,6 +361,9 @@ A recovery item that permits abandonment must be deleted after explicit user con if err != nil { return err } + if provider, _ := cmd.Flags().GetString("provider"); update && provider == "kernel" { + return fmt.Errorf("Kernel cards cannot be updated; delete the item and create a new one") + } return getVaultsHandler(cmd).SaveCard(cmd.Context(), args[0], args[1], param.Override[kernel.CardVaultItemSpecUnionParam](spec), update, vaultOutput(cmd)) }} addVaultSpecFlags(cmd) @@ -350,7 +372,7 @@ A recovery item that permits abandonment must be deleted after explicit user con } func addVaultSpecFlags(cmd *cobra.Command) { - cmd.Flags().String("provider", "", "Provider: link or agentcard (required)") + cmd.Flags().String("provider", "", "Provider: link, agentcard, or kernel (required)") cmd.Flags().String("spec", "", "Raw JSON specification object (required); see types and examples above") _ = cmd.MarkFlagRequired("provider") _ = cmd.MarkFlagRequired("spec") @@ -358,8 +380,8 @@ func addVaultSpecFlags(cmd *cobra.Command) { func vaultSpecFromFlags(cmd *cobra.Command) (map[string]json.RawMessage, error) { provider, _ := cmd.Flags().GetString("provider") - if provider != "link" && provider != "agentcard" { - return nil, fmt.Errorf("--provider must be link or agentcard") + if provider != "link" && provider != "agentcard" && provider != "kernel" { + return nil, fmt.Errorf("--provider must be link, agentcard, or kernel") } raw, _ := cmd.Flags().GetString("spec") var spec map[string]json.RawMessage diff --git a/cmd/vaults_credentials.go b/cmd/vaults_credentials.go index 555521fd..58a66854 100644 --- a/cmd/vaults_credentials.go +++ b/cmd/vaults_credentials.go @@ -76,6 +76,14 @@ and requests instead of account. Supply either account or both secrets, never bo or stdin; they are write-only and never displayed. Never ask an end user for them. Replace the token with items invoke 1pw_update_access_token --spec-file.` +const vaultManagedAuthCredentialHelp = `Managed auth credentials (spec provider "managed_auth"): reference a managed auth +connection in the vault's project that already has a saved Kernel credential, with +connection_id (from auth connections list) and an optional description. The item +stores no values; fill reads the connection's saved credential at fill time, so +managed auth updates apply immediately. Items are created ready; state.fields lists +fill binding names without values. No collection form is offered and update returns +409. Deleting the item leaves the connection and its credential unchanged.` + const vaultCredentialHelp = `Create credentials for a website. ` + vaultCredentialPathsHelp + ` @@ -109,7 +117,9 @@ Collection URLs are bearer credentials: share only with the intended user. ` + vaultOnePasswordCredentialHelp + ` -` + vaultOnePasswordStoredTokenHelp +` + vaultOnePasswordStoredTokenHelp + ` + +` + vaultManagedAuthCredentialHelp func newVaultCredentialsCommand() *cobra.Command { group := &cobra.Command{Use: "credentials", Short: "Collect, update, and fill user credentials", Long: vaultCredentialHelp} @@ -148,6 +158,11 @@ JSON # 1Password brokered approval (account is the connected credential_account key) kernel vaults credentials create user-vault github --spec-file - <<'JSON' {"provider":"1password","account":"onepassword","requests":{"version":2,"entries":[{"type":"login","parameters":{"website":"https://github.com"}}]}} +JSON + + # Managed auth connection with a saved credential + kernel vaults credentials create user-vault amazon --spec-file - <<'JSON' +{"provider":"managed_auth","connection_id":"ma_abc123xyz","description":"Amazon"} JSON` } cmd.Flags().String("spec-file", "", "Credential spec JSON file (use '-' for stdin; maximum 128 KiB)") @@ -282,8 +297,15 @@ func credentialSpecInput(data []byte) (kernel.CredentialVaultItemSpecInputUnionP return kernel.CredentialVaultItemSpecInputUnionParam{}, fmt.Errorf("1Password credential spec requires requests with 1-5 login entries") } return kernel.CredentialVaultItemSpecInputUnionParam{Of1password: &spec}, nil + case "managed_auth": + var spec kernel.ManagedAuthCredentialVaultItemSpecInputParam + if json.Unmarshal(data, &spec) != nil || strings.TrimSpace(spec.ConnectionID) == "" { + return kernel.CredentialVaultItemSpecInputUnionParam{}, fmt.Errorf("managed auth credential spec requires connection_id") + } + spec.Provider = kernel.ManagedAuthCredentialVaultItemSpecInputProviderManagedAuth + return kernel.CredentialVaultItemSpecInputUnionParam{OfManagedAuth: &spec}, nil default: - return kernel.CredentialVaultItemSpecInputUnionParam{}, fmt.Errorf("credential spec provider must be kernel or 1password") + return kernel.CredentialVaultItemSpecInputUnionParam{}, fmt.Errorf("credential spec provider must be kernel, 1password, or managed_auth") } } diff --git a/cmd/vaults_credentials_test.go b/cmd/vaults_credentials_test.go index 008ea306..fd95f0d8 100644 --- a/cmd/vaults_credentials_test.go +++ b/cmd/vaults_credentials_test.go @@ -218,5 +218,5 @@ func TestCredentialSpecInputProvider(t *testing.T) { assert.EqualValues(t, "kernel", spec.OfKernel.Provider) _, err = credentialSpecInput([]byte(`{"provider":"bitwarden","fields":[{"name":"password","type":"password"}]}`)) - assert.EqualError(t, err, "credential spec provider must be kernel or 1password") + assert.EqualError(t, err, "credential spec provider must be kernel, 1password, or managed_auth") } diff --git a/cmd/vaults_help.go b/cmd/vaults_help.go index ea948f44..96e82466 100644 --- a/cmd/vaults_help.go +++ b/cmd/vaults_help.go @@ -45,6 +45,13 @@ type AgentCardWalletSpec = { provider_config?: ProviderConfigReference; // omit for Kernel-managed credentials user_id?: string; // usr_...; enrolled in this organization under the SAME config }; + +// Kernel-managed Visa/Mastercard agentic network token enrollment. Creation returns a +// card_enrollment action: the cardholder enters the card on a Kernel-hosted page. +// The card number never reaches Kernel or the CLI. No provider config or tokens. +type KernelWalletSpec = { + provider: "kernel"; +}; ` const vaultCardSpecHelp = ` @@ -73,6 +80,18 @@ type AgentCardCardSpec = { checkout_origin?: string; // top-level checkout origin for autopilot matching; update omission removes it }; +// One live purchase with a Kernel-enrolled card. Authorize obtains a network token and +// one-time code for fill on merchant_url's origin until expires_at. Visa purchases are +// not yet supported (authorize returns 400). Updates are not supported. +type KernelCardSpec = { + provider: "kernel"; + wallet: string; // Kernel wallet item key + amount: number; // integer minor units; 1..50000 + currency: string; // ISO 4217 three letters + merchant_name: string; // 1..255 characters + merchant_url: string; // HTTPS merchant checkout URL; fill is locked to its origin +}; + type LinkLineItem = { name: string; quantity?: number; // integer >= 1 diff --git a/cmd/vaults_managed_auth_test.go b/cmd/vaults_managed_auth_test.go new file mode 100644 index 00000000..5de2da2a --- /dev/null +++ b/cmd/vaults_managed_auth_test.go @@ -0,0 +1,47 @@ +package cmd + +import ( + "io" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const managedAuthCredentialFixture = `{"id":"credential-3","key":"amazon","type":"credential","version":1,"spec":{"provider":"managed_auth","connection_id":"ma_abc123xyz","description":"Amazon"},"state":{"provider":"managed_auth","status":"ready","fields":{"username":{"type":"email"},"password":{"type":"password"}}},"available_operations":[{"type":"fill","description":"Fill the login form."}],"available_expansions":[],"created_at":"2026-09-01T00:00:00Z","updated_at":"2026-09-01T00:00:00Z"}` + +func TestCredentialSpecInputManagedAuth(t *testing.T) { + spec, err := credentialSpecInput([]byte(`{"provider":"managed_auth","connection_id":"ma_abc123xyz","description":"Amazon"}`)) + require.NoError(t, err) + require.NotNil(t, spec.OfManagedAuth) + assert.Equal(t, "ma_abc123xyz", spec.OfManagedAuth.ConnectionID) + assert.Equal(t, "Amazon", spec.OfManagedAuth.Description.Value) + + _, err = credentialSpecInput([]byte(`{"provider":"managed_auth"}`)) + assert.EqualError(t, err, "managed auth credential spec requires connection_id") +} + +func TestCredentialCreateManagedAuth(t *testing.T) { + t.Setenv("KERNEL_PROJECT", "") + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPut, r.Method) + assert.Equal(t, "/vaults/user/items/amazon", r.URL.Path) + body, err := io.ReadAll(r.Body) + require.NoError(t, err) + assert.JSONEq(t, `{"type":"credential","spec":{"provider":"managed_auth","connection_id":"ma_abc123xyz","description":"Amazon"}}`, string(body)) + w.Header().Set("Content-Type", "application/json") + io.WriteString(w, managedAuthCredentialFixture) + }) + spec := `{"provider":"managed_auth","connection_id":"ma_abc123xyz","description":"Amazon"}` + out, _, err := executeVaultInputCommand(t, client, spec, "vaults", "credentials", "create", "user", "amazon", "--spec-file", "-", "-o", "json") + require.NoError(t, err) + assert.Contains(t, out, `"connection_id": "ma_abc123xyz"`) + assert.Contains(t, out, `"type": "password"`) + assert.NotContains(t, out, `"has_value"`) + + out, text, err := executeVaultInputCommand(t, client, spec, "vaults", "credentials", "create", "user", "amazon", "--spec-file", "-") + require.NoError(t, err) + assert.Contains(t, out+text, "Managed auth connection (immutable)") + assert.Contains(t, out+text, "ma_abc123xyz") +} diff --git a/cmd/vaults_onepassword_test.go b/cmd/vaults_onepassword_test.go index ec7d6b72..e6f3aef7 100644 --- a/cmd/vaults_onepassword_test.go +++ b/cmd/vaults_onepassword_test.go @@ -79,7 +79,7 @@ func TestCredentialCreateOnePassword(t *testing.T) { {`{"provider":"1password","account":"onepassword"}`, "1-5 login entries"}, {`{"provider":"1password","access_token":"token-secret","integration_key":"key-secret","website":"https://github.com"}`, "1-5 login entries"}, {`{"provider":"1password","account":"onepassword","requests":{"version":2,"entries":[` + strings.Repeat(entry+",", 5) + entry + `]}}`, "1-5 login entries"}, - {`{"provider":"lastpass","fields":[{"name":"password","type":"password"}]}`, "kernel or 1password"}, + {`{"provider":"lastpass","fields":[{"name":"password","type":"password"}]}`, "kernel, 1password, or managed_auth"}, } { _, _, err := executeVaultCommand(t, client, "vaults", "credentials", "create", "user", "github", "--spec-file", credentialSpecFile(t, tc.spec)) require.ErrorContains(t, err, tc.err, tc.spec) diff --git a/cmd/vaults_operation_params.go b/cmd/vaults_operation_params.go index 283a1898..bf631114 100644 --- a/cmd/vaults_operation_params.go +++ b/cmd/vaults_operation_params.go @@ -16,6 +16,7 @@ type vaultOperationParams struct { Checkout *kernel.VaultCheckoutContextParam // OnePassword is a complete 1pw_* request body; Invoke supplies the vault. OnePassword *kernel.VaultItemPerformOperationParams + WebMCP *kernel.WebmcpInvokeVaultItemOperationRequestParam } func isOnePasswordOperation(operation string) bool { @@ -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 == "prepare_checkout" || operation == "webmcp_invoke" || (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") + } + request, err := parseVaultWebMCPParams(raw) + if err != nil { + return nil, err + } + return &vaultOperationParams{WebMCP: request}, 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, prepare_checkout, webmcp_invoke, and 1Password operations; authorize takes no parameters") } return nil, nil } diff --git a/cmd/vaults_output.go b/cmd/vaults_output.go index aa5ecff8..09f19569 100644 --- a/cmd/vaults_output.go +++ b/cmd/vaults_output.go @@ -45,7 +45,7 @@ var vaultItemFields = vaultOutputFields{ "spec": { "provider": nil, "wallet": nil, "user_id": nil, "payment_method_id": nil, "card_id": nil, "checkout_origin": nil, "amount": nil, "currency": nil, "merchant": nil, "merchant_name": nil, "merchant_url": nil, - "context": nil, "expires_at": nil, "description": nil, "account": nil, + "context": nil, "expires_at": nil, "description": nil, "account": nil, "connection_id": nil, "requests": onePasswordRequestFields, "fields": vaultFieldsOf("name label type required sensitive"), "provider_config": vaultFieldsOf("id name"), @@ -62,8 +62,8 @@ var vaultItemFields = vaultOutputFields{ "id": nil, "state": nil, "goal": nil, "createdAt": nil, "has_autofill_token": nil, "granted_count": nil, "request": onePasswordRequestFields, "entries": onePasswordRequestEntryFields, }, - "fields": {"*": vaultFieldsOf("has_value")}, - "masks": vaultFieldsOf("brand last4"), + "fields": {"*": vaultFieldsOf("has_value type")}, + "masks": vaultFieldsOf("brand last4 token_last4"), "aliases": vaultFieldsOf("number cvc exp_month exp_year"), "preparation": vaultFieldsOf("id status browser_id merchant_origin environment psp created_at expires_at approval_url"), "authorization": vaultFieldsOf("id status psp merchant amount amount_cents currency created_at expires_at approval_url browser_id reason psp_error_code expected_cents actual_cents amount_authority amount_verified charged_amount_cents charged_currency charged_kind replay_attempted replay_status replay_delivered"), @@ -155,7 +155,8 @@ func preservePublicCredentialValues(source, result vaultJSON) error { Sensitive *bool `json:"sensitive"` } var spec struct { - Fields []definition `json:"fields"` + Provider string `json:"provider"` + Fields []definition `json:"fields"` } var values struct { Fields map[string]struct { @@ -166,6 +167,10 @@ func preservePublicCredentialValues(source, result vaultJSON) error { if json.Unmarshal(source["spec"], &spec) != nil || json.Unmarshal(source["state"], &values) != nil || values.Fields == nil { return nil } + // Managed auth state lists binding names and types only; values are never returned. + if spec.Provider == "managed_auth" { + return nil + } definitions := make(map[string]definition, len(spec.Fields)) for _, field := range spec.Fields { definitions[field.Name] = field @@ -339,6 +344,8 @@ func printVaultItem(item *kernel.VaultItemUnion, output string) error { } } } + } else if item.Spec.Provider == "managed_auth" { + rows = append(rows, []string{"Managed auth connection (immutable)", item.Spec.ConnectionID}) } else { pterm.Info.Println("Use -o json for field definitions, presence, and non-sensitive values; sensitive values are omitted") } @@ -371,6 +378,15 @@ func printVaultItem(item *kernel.VaultItemUnion, output string) error { if item.Spec.Provider == "agentcard" && item.Spec.CheckoutOrigin != "" { rows = append(rows, []string{"Checkout origin", item.Spec.CheckoutOrigin}) } + if item.Spec.Provider == "kernel" && item.Spec.MerchantURL != "" { + rows = append(rows, []string{"Merchant URL", item.Spec.MerchantURL}) + } + if masks := item.State.Masks; masks.Last4 != "" || masks.TokenLast4 != "" { + rows = append(rows, []string{"Card last4", util.OrDash(masks.Last4)}) + if masks.TokenLast4 != "" { + rows = append(rows, []string{"Network token last4", masks.TokenLast4}) + } + } } if item.State.JSON.Domains.Valid() { rows = append(rows, []string{"Permitted domains (provider-assigned)", strings.Join(item.State.Domains, ", ")}) @@ -447,6 +463,10 @@ func printVaultItemGuidance(item *kernel.VaultItemUnion, actions vaultItemAction pterm.Info.Println("Ready means the account owner approved access, not that sign-in succeeded. 1pw_fill submits the form; inspect the page afterward. Never retry a request or fill automatically; after an uncertain outcome, do not delete and recreate the item.") return } + if item.Type == "credential" && item.Spec.Provider == "managed_auth" { + pterm.Info.Println("Fill reads the managed auth connection's saved credential at fill time; use -o json for fill binding names. Ready means a saved credential exists, not that login succeeded. Fill only when advertised; fill does not submit the form.") + return + } if item.Type == "credential" { if actions.RequiredAction != "" { pterm.Info.Println("Share the collection URL with the user to complete the credential form. Observe readiness with items get --wait 60; for edits to an already-ready item, compare versions without --wait.") diff --git a/cmd/vaults_wallet_spec.go b/cmd/vaults_wallet_spec.go index 8136b3a1..1cf5ded1 100644 --- a/cmd/vaults_wallet_spec.go +++ b/cmd/vaults_wallet_spec.go @@ -41,7 +41,11 @@ func vaultWalletSpecFromFlags(cmd *cobra.Command) (kernel.VaultItemUpsertParamsB return kernel.VaultItemUpsertParamsBodyWalletSpecUnion{}, err } provider, _ := cmd.Flags().GetString("provider") - if provider == "agentcard" { + if provider == "kernel" { + if reference != nil || cmd.Flags().Changed("tokens-file") { + return kernel.VaultItemUpsertParamsBodyWalletSpecUnion{}, fmt.Errorf("Kernel wallets use Kernel-managed credentials; omit provider config and --tokens-file") + } + } else if provider == "agentcard" { if cmd.Flags().Changed("tokens-file") { return kernel.VaultItemUpsertParamsBodyWalletSpecUnion{}, fmt.Errorf("--tokens-file is only for imported Link wallet grants") } diff --git a/cmd/vaults_wallet_spec_test.go b/cmd/vaults_wallet_spec_test.go index 9711ddba..0d6f2be1 100644 --- a/cmd/vaults_wallet_spec_test.go +++ b/cmd/vaults_wallet_spec_test.go @@ -63,3 +63,37 @@ func TestVaultImportedAuthorizationPreservesRawFields(t *testing.T) { }) } } + +func TestVaultKernelWalletSendsProviderOnly(t *testing.T) { + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + var body struct { + Type string `json:"type"` + Spec map[string]json.RawMessage `json:"spec"` + } + require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + assert.Equal(t, "wallet", body.Type) + raw, err := json.Marshal(body.Spec) + require.NoError(t, err) + assert.JSONEq(t, `{"provider":"kernel"}`, string(raw)) + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, connectedWalletFixture) + }) + _, _, err := executeVaultInputCommand(t, client, "", "vaults", "wallets", "create", "checkout", "wallet-1", "--provider", "kernel", "--spec", "{}", "-o", "json") + require.NoError(t, err) +} + +func TestVaultKernelProviderRejectsUnsupportedInputs(t *testing.T) { + for name, args := range map[string][]string{ + "provider config": {"wallets", "create", "checkout", "wallet-1", "--provider", "kernel", "--spec", "{}", "--provider-config-name", "cfg"}, + "tokens file": {"wallets", "create", "checkout", "wallet-1", "--provider", "kernel", "--spec", "{}", "--tokens-file", "-"}, + "card update": {"cards", "update", "checkout", "card-1", "--provider", "kernel", "--spec", "{}"}, + } { + t.Run(name, func(t *testing.T) { + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path) + }) + _, _, err := executeVaultInputCommand(t, client, "", append([]string{"vaults"}, args...)...) + require.Error(t, err) + }) + } +} diff --git a/cmd/vaults_webmcp.go b/cmd/vaults_webmcp.go new file mode 100644 index 00000000..16c0e777 --- /dev/null +++ b/cmd/vaults_webmcp.go @@ -0,0 +1,159 @@ +package cmd + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "net/url" + "strings" + + kernel "github.com/kernel/kernel-go-sdk" + "github.com/kernel/kernel-go-sdk/option" + "github.com/pterm/pterm" +) + +const vaultWebMCPUncertain = "the tool may have run and performed side effects; inspect the browser page and do not retry automatically" + +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 + } + request := kernel.WebmcpInvokeVaultItemOperationRequestParam{Type: kernel.WebmcpInvokeVaultItemOperationRequestTypeWebmcpInvoke} + if json.Unmarshal(object["browser_id"], &request.BrowserID) != nil || strings.TrimSpace(request.BrowserID) == "" { + return nil, fmt.Errorf("browser_id must be a non-empty browser session ID, not a name") + } + if json.Unmarshal(object["tool_ref"], &request.ToolRef) != nil || strings.TrimSpace(request.ToolRef) == "" || len(request.ToolRef) > 128 { + return nil, fmt.Errorf("tool_ref must be a non-empty tool reference of at most 128 bytes from browsers webmcp list") + } + if json.Unmarshal(object["page_url"], &request.PageURL) != nil { + return nil, fmt.Errorf("page_url must be the exact absolute URL from the discovered tool source") + } + if u, err := url.ParseRequestURI(request.PageURL); err != nil || u.Scheme == "" { + return nil, fmt.Errorf("page_url must be the exact absolute URL from the discovered tool source") + } + var input map[string]json.RawMessage + if json.Unmarshal(object["input"], &input) != nil || input == nil { + return nil, fmt.Errorf("input must be a JSON object with a null slot at each binding path") + } + request.Input = make(map[string]any, len(input)) + for key, value := range input { + // Keep raw values so page-provided numbers and nulls are sent unchanged. + request.Input[key] = value + } + var bindings []json.RawMessage + if json.Unmarshal(object["bindings"], &bindings) != nil || len(bindings) < 1 || len(bindings) > 32 { + return nil, fmt.Errorf("bindings must be an array of 1-32 field bindings") + } + request.Bindings = make([]kernel.VaultWebmcpBindingParam, 0, len(bindings)) + for i, rawBinding := range bindings { + fields, err := vaultParamsObject(string(rawBinding), "field input_path format") + if err != nil { + return nil, fmt.Errorf("bindings[%d]: %w", i, err) + } + var binding kernel.VaultWebmcpBindingParam + if json.Unmarshal(fields["field"], &binding.Field) != nil || strings.TrimSpace(binding.Field) == "" { + return nil, fmt.Errorf("bindings[%d].field must be a non-empty field name", i) + } + if json.Unmarshal(fields["input_path"], &binding.InputPath) != nil || !strings.HasPrefix(binding.InputPath, "/") { + return nil, fmt.Errorf("bindings[%d].input_path must be a JSON Pointer to a null slot in input, such as /password", i) + } + if value, present := fields["format"]; present { + var format string + if json.Unmarshal(value, &format) != nil || (format != "MM/YY" && format != "MM/YYYY") { + return nil, fmt.Errorf("bindings[%d].format must be MM/YY or MM/YYYY", i) + } + binding.Format = kernel.Opt(format) + } + request.Bindings = append(request.Bindings, binding) + } + if value, ok := object["timeout_sec"]; ok { + var timeout *int64 + if json.Unmarshal(value, &timeout) != nil || timeout == nil || *timeout < 1 || *timeout > 120 { + return nil, fmt.Errorf("timeout_sec must be an integer between 1 and 120") + } + request.TimeoutSec = kernel.Opt(*timeout) + } + return &request, nil +} + +func (c VaultsCmd) webmcpInvoke(ctx context.Context, vault, key string, request *kernel.WebmcpInvokeVaultItemOperationRequestParam, output string) error { + // A lost response can hide completed side effects, so never retry an invocation. + response, err := c.vaults.Items.PerformOperation(ctx, key, kernel.VaultItemPerformOperationParams{IDOrName: vault, OfWebmcpInvoke: request}, option.WithMaxRetries(0)) + if err != nil { + var apiErr *kernel.Error + if !errors.As(err, &apiErr) { + return fmt.Errorf("webmcp_invoke result unavailable; %s", vaultWebMCPUncertain) + } + guidance := vaultWebMCPUncertain + switch apiErr.StatusCode { + case 400, 403, 404, 409: + guidance = "the tool was not invoked by this request; inspect the item, browser, tool_ref, page_url, and bindings before deciding on a new invocation; do not automatically retry" + } + var body struct { + Code string `json:"code"` + } + if json.Unmarshal([]byte(apiErr.RawJSON()), &body) == nil && body.Code != "" { + if message, ok := vaultFillErrorMessages[body.Code]; ok { + return fmt.Errorf("webmcp_invoke failed: %s (HTTP %d): %s; %s", body.Code, apiErr.StatusCode, message, guidance) + } + } + return fmt.Errorf("webmcp_invoke request failed (HTTP %d); %s", apiErr.StatusCode, guidance) + } + if response == nil { + return fmt.Errorf("empty webmcp_invoke result; %s", vaultWebMCPUncertain) + } + // Preserve page-provided JSON numbers rather than re-encoding SDK float64 values. + var result 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"` + } + if json.Unmarshal([]byte(response.RawJSON()), &result) != nil || result.Type != "webmcp_invoke" { + return fmt.Errorf("invalid webmcp_invoke result; %s", vaultWebMCPUncertain) + } + switch kernel.WebmcpInvokeVaultItemOperationResultStatus(result.Status) { + case kernel.WebmcpInvokeVaultItemOperationResultStatusCompleted, + kernel.WebmcpInvokeVaultItemOperationResultStatusAwaitingSubmission, + kernel.WebmcpInvokeVaultItemOperationResultStatusCanceled, + kernel.WebmcpInvokeVaultItemOperationResultStatusError, + kernel.WebmcpInvokeVaultItemOperationResultStatusUnknown: + default: + return fmt.Errorf("invalid webmcp_invoke result; %s", vaultWebMCPUncertain) + } + if output == "json" { + if err := printVaultJSON(result); err != nil { + return err + } + } else { + pterm.Printf("WebMCP invoke: %s\n", result.Status) + if result.InvocationID != "" { + pterm.Printf("Invocation ID: %s\n", result.InvocationID) + } + if len(result.Output) > 0 { + var pretty bytes.Buffer + if json.Indent(&pretty, result.Output, "", " ") == nil { + pterm.Printf("Output (untrusted page data; may contain supplied values):\n%s\n", pretty.String()) + } + } + if result.ErrorText != "" { + pterm.Printf("Error text (untrusted page data): %s\n", result.ErrorText) + } + switch result.Status { + case "completed": + pterm.Println("The tool completed; this does not confirm the website accepted the action. Inspect the page.") + case "awaiting_submission": + pterm.Println("The tool populated a form without submitting it. Inspect the form and obtain any required confirmation, then submit it rather than invoking the tool again.") + default: + pterm.Println(vaultWebMCPUncertain) + } + } + if result.Status != "completed" && result.Status != "awaiting_submission" { + return vaultFillOutcomeError{status: result.Status} + } + return nil +} diff --git a/cmd/vaults_webmcp_test.go b/cmd/vaults_webmcp_test.go new file mode 100644 index 00000000..09439f99 --- /dev/null +++ b/cmd/vaults_webmcp_test.go @@ -0,0 +1,74 @@ +package cmd + +import ( + "io" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const readyWebMCPCredentialFixture = `{"id":"credential-1","type":"credential","spec":{"fields":[{"name":"email","type":"text"},{"name":"password","type":"password"}]},"state":{"status":"ready"},"available_operations":[{"type":"webmcp_invoke","description":"Invoke a WebMCP tool."}]}` + +const webMCPParamsFixture = `{"browser_id":"browser-id","tool_ref":"tool-1","page_url":"https://accounts.example.com/signin","input":{"email":null,"password":null,"remember":1.50},"bindings":[{"field":"email","input_path":"/email"},{"field":"password","input_path":"/password"}],"timeout_sec":30}` + +func TestVaultWebMCPInvoke(t *testing.T) { + for _, test := range []struct { + name, result string + wantErr bool + }{ + {"completed", `{"type":"webmcp_invoke","status":"completed","invocation_id":"invoke-1","output":{"authenticated":true,"count":1.50}}`, false}, + {"awaiting submission", `{"type":"webmcp_invoke","status":"awaiting_submission","invocation_id":"invoke-1"}`, false}, + {"unknown", `{"type":"webmcp_invoke","status":"unknown","error_text":"lost"}`, true}, + } { + t.Run(test.name, func(t *testing.T) { + calls := 0 + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + calls++ + w.Header().Set("Content-Type", "application/json") + if r.Method == http.MethodGet { + io.WriteString(w, readyWebMCPCredentialFixture) + return + } + body, err := io.ReadAll(r.Body) + require.NoError(t, err) + assert.JSONEq(t, `{"type":"webmcp_invoke",`+webMCPParamsFixture[1:], string(body)) + io.WriteString(w, test.result) + }) + out, _, err := executeVaultCommand(t, client, "vaults", "items", "invoke", "vault", "item", "webmcp_invoke", "--params", webMCPParamsFixture, "-o", "json") + if test.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + } + assert.JSONEq(t, test.result, out) + assert.Equal(t, 2, calls) + }) + } +} + +func TestVaultWebMCPInvokeValidation(t *testing.T) { + for _, params := range []string{ + `{"tool_ref":"t","page_url":"https://a.example","input":{"p":null},"bindings":[{"field":"password","input_path":"/p"}]}`, + `{"browser_id":"b","page_url":"https://a.example","input":{"p":null},"bindings":[{"field":"password","input_path":"/p"}]}`, + `{"browser_id":"b","tool_ref":"t","page_url":"not a url","input":{"p":null},"bindings":[{"field":"password","input_path":"/p"}]}`, + `{"browser_id":"b","tool_ref":"t","page_url":"https://a.example","input":[],"bindings":[{"field":"password","input_path":"/p"}]}`, + `{"browser_id":"b","tool_ref":"t","page_url":"https://a.example","input":{"p":null},"bindings":[]}`, + `{"browser_id":"b","tool_ref":"t","page_url":"https://a.example","input":{"p":null},"bindings":[{"field":"password","input_path":"p"}]}`, + `{"browser_id":"b","tool_ref":"t","page_url":"https://a.example","input":{"p":null},"bindings":[{"field":"password","input_path":"/p","value":"secret"}]}`, + `{"browser_id":"b","tool_ref":"t","page_url":"https://a.example","input":{"p":null},"bindings":[{"field":"password","input_path":"/p"}],"timeout_sec":121}`, + `{"type":"webmcp_invoke","browser_id":"b","tool_ref":"t","page_url":"https://a.example","input":{"p":null},"bindings":[{"field":"password","input_path":"/p"}]}`, + } { + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path) + }) + _, _, err := executeVaultCommand(t, client, "vaults", "items", "invoke", "vault", "item", "webmcp_invoke", "--params", params) + assert.Error(t, err, params) + } + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path) + }) + _, _, err := executeVaultCommand(t, client, "vaults", "items", "invoke", "vault", "item", "webmcp_invoke") + assert.ErrorContains(t, err, "webmcp_invoke requires --params") +} diff --git a/go.mod b/go.mod index 717881ee..2e424b9d 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.119.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..75816424 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.119.0 h1:BF0VowtcKTot27DdK3dT5GngardUuUYkbn0GH9QLBCc= +github.com/kernel/kernel-go-sdk v0.119.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=