diff --git a/README.md b/README.md index 55d749f2..cf46c8ed 100644 --- a/README.md +++ b/README.md @@ -410,6 +410,7 @@ cannot switch projects. | `kernel vaults create --name ` | Create or retrieve the vault with that immutable name | | `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 get-encryption-key ` | Get the public key custom collection apps use to send `encrypted_value` | | `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 | | `kernel vaults wallets payment-methods ` | Fetch advertised live payment methods; JSON is the item with `expanded.payment_methods` | diff --git a/cmd/browsers.go b/cmd/browsers.go index 9f82181f..e47632ac 100644 --- a/cmd/browsers.go +++ b/cmd/browsers.go @@ -201,6 +201,26 @@ func parseMemoryFlag(memory string) (kernel.BrowserMemoryRequest, error) { return "", fmt.Errorf("invalid --memory value: %s (must be one of %s)", memory, strings.Join(availableMemorySizes(), ", ")) } +// availableVideoMemorySizes returns the video memory (VRAM) sizes the API +// accepts for a GPU browser session. +func availableVideoMemorySizes() []string { + return []string{"2GiB", "4GiB"} +} + +// parseVideoMemoryFlag validates a --video-memory value. An empty value means the +// flag was not set, which lets the API apply its default (2GiB). +func parseVideoMemoryFlag(videoMemory string) (kernel.BrowserVideoMemory, error) { + if videoMemory == "" { + return "", nil + } + for _, m := range availableVideoMemorySizes() { + if strings.EqualFold(videoMemory, m) { + return kernel.BrowserVideoMemory(m), nil + } + } + return "", fmt.Errorf("invalid --video-memory value: %s (must be one of %s)", videoMemory, strings.Join(availableVideoMemorySizes(), ", ")) +} + // maxPrivateHosts mirrors the API's cap on network.private_hosts entries. const maxPrivateHosts = 32 @@ -468,6 +488,7 @@ type BrowsersCreateInput struct { Headless BoolFlag GPU BoolFlag Memory string + VideoMemory string InvocationID string Kiosk BoolFlag ProfileID string @@ -705,6 +726,13 @@ func (b BrowsersCmd) Create(ctx context.Context, in BrowsersCreateInput) error { if memory != "" { params.Memory = memory } + videoMemory, err := parseVideoMemoryFlag(in.VideoMemory) + if err != nil { + return err + } + if videoMemory != "" { + params.VideoMemory = videoMemory + } if in.InvocationID != "" { params.InvocationID = kernel.Opt(in.InvocationID) } @@ -972,6 +1000,9 @@ func (b BrowsersCmd) Get(ctx context.Context, in BrowsersGetInput) error { tableData = append(tableData, []string{"Stealth", fmt.Sprintf("%t", browser.Stealth)}) tableData = append(tableData, []string{"GPU", fmt.Sprintf("%t", browser.GPU)}) tableData = append(tableData, []string{"Memory", util.OrDash(string(browser.Memory))}) + if browser.VideoMemory != "" { + tableData = append(tableData, []string{"Video Memory", string(browser.VideoMemory)}) + } tableData = append(tableData, []string{"Kiosk Mode", fmt.Sprintf("%t", browser.KioskMode)}) if browser.Viewport.Width > 0 && browser.Viewport.Height > 0 { viewportStr := fmt.Sprintf("%dx%d", browser.Viewport.Width, browser.Viewport.Height) @@ -3247,6 +3278,7 @@ unrestricted code execution inside the browser VM and is not sandboxed.`, browsersCreateCmd.Flags().BoolP("headless", "H", false, "Launch browser without GUI access") browsersCreateCmd.Flags().Bool("gpu", false, "Launch browser with hardware-accelerated GPU rendering") browsersCreateCmd.Flags().String("memory", "", "Memory for a headful, non-GPU browser session: '8GiB' (default) or '16GiB'") + browsersCreateCmd.Flags().String("video-memory", "", "Video memory (VRAM) for a GPU browser session (requires --gpu): '2GiB' (default, 4 vCPU/6GiB memory) or '4GiB' (8 vCPU/12GiB memory)") browsersCreateCmd.Flags().String("invocation-id", "", "Associate the browser session with an invocation") browsersCreateCmd.Flags().Bool("kiosk", false, "Launch browser in kiosk mode") browsersCreateCmd.Flags().IntP("timeout", "t", 60, "Timeout in seconds for the browser session") @@ -3387,6 +3419,7 @@ func runBrowsersCreate(cmd *cobra.Command, args []string) error { headlessVal, _ := cmd.Flags().GetBool("headless") gpuVal, _ := cmd.Flags().GetBool("gpu") memory, _ := cmd.Flags().GetString("memory") + videoMemory, _ := cmd.Flags().GetString("video-memory") invocationID, _ := cmd.Flags().GetString("invocation-id") kioskVal, _ := cmd.Flags().GetBool("kiosk") timeout, _ := cmd.Flags().GetInt("timeout") @@ -3544,6 +3577,7 @@ func runBrowsersCreate(cmd *cobra.Command, args []string) error { Headless: BoolFlag{Set: cmd.Flags().Changed("headless"), Value: headlessVal}, GPU: BoolFlag{Set: cmd.Flags().Changed("gpu"), Value: gpuVal}, Memory: memory, + VideoMemory: videoMemory, InvocationID: invocationID, Kiosk: BoolFlag{Set: cmd.Flags().Changed("kiosk"), Value: kioskVal}, ProfileID: profileID, diff --git a/cmd/browsers_test.go b/cmd/browsers_test.go index e3af7a35..cfab87d7 100644 --- a/cmd/browsers_test.go +++ b/cmd/browsers_test.go @@ -900,6 +900,35 @@ func TestBrowsersCreate_WithMemory(t *testing.T) { assert.Error(t, b.Create(context.Background(), BrowsersCreateInput{Memory: "4GiB"})) } +func TestBrowsersCreate_WithVideoMemory(t *testing.T) { + setupStdoutCapture(t) + + var captured kernel.BrowserNewParams + fake := &FakeBrowsersService{ + NewFunc: func(ctx context.Context, body kernel.BrowserNewParams, opts ...option.RequestOption) (*kernel.BrowserNewResponse, error) { + captured = body + return &kernel.BrowserNewResponse{SessionID: "sess-video-memory"}, nil + }, + } + b := BrowsersCmd{browsers: fake} + + err := b.Create(context.Background(), BrowsersCreateInput{GPU: BoolFlag{Set: true, Value: true}, VideoMemory: "4gib"}) + require.NoError(t, err) + assert.Equal(t, kernel.BrowserVideoMemory4GiB, captured.VideoMemory) + + raw, err := captured.MarshalJSON() + require.NoError(t, err) + assert.Contains(t, string(raw), `"video_memory":"4GiB"`) + + // Omitting the flag sends nothing; the server defaults to 2GiB. + err = b.Create(context.Background(), BrowsersCreateInput{GPU: BoolFlag{Set: true, Value: true}}) + require.NoError(t, err) + assert.Empty(t, captured.VideoMemory) + + // An unsupported size is rejected before the request is made. + assert.Error(t, b.Create(context.Background(), BrowsersCreateInput{VideoMemory: "8GiB"})) +} + func TestBrowsersCreate_WithChromePolicy(t *testing.T) { setupStdoutCapture(t) diff --git a/cmd/vaults.go b/cmd/vaults.go index dbd7968d..93f589b1 100644 --- a/cmd/vaults.go +++ b/cmd/vaults.go @@ -51,6 +51,14 @@ func (c VaultsCmd) Get(ctx context.Context, vault, output string) error { return printVault(v, output) } +func (c VaultsCmd) GetEncryptionKey(ctx context.Context, vault, output string) error { + k, err := c.vaults.GetEncryptionKey(ctx, vault, option.WithMaxRetries(0)) + if err != nil { + return util.CleanedUpSdkError{Err: err} + } + return printVaultEncryptionKey(k, output) +} + 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") diff --git a/cmd/vaults_commands.go b/cmd/vaults_commands.go index fadbd6a1..3d7922d7 100644 --- a/cmd/vaults_commands.go +++ b/cmd/vaults_commands.go @@ -132,7 +132,23 @@ JSON output preserves returned public fields but omits unknown/opaque provider d return getVaultsHandler(cmd).Get(cmd.Context(), args[0], vaultOutput(cmd)) }} addVaultJSONOutputFlag(get) - cmd.AddCommand(create, list, get, newVaultDeleteCommand(false)) + + encryptionKey := &cobra.Command{Use: "get-encryption-key ", Short: "Get a vault's public key for browser-encrypted credential values", Args: cobra.ExactArgs(1), PreRunE: vaultPreRun, + Long: `Get the public key that custom credential collection web apps use to encrypt values in the browser. + +Use this only if you run your own credential collection web app and want values +encrypted in the browser, sent to your backend still encrypted, and forwarded to +Kernel's API still encrypted. In every other case, including server-side code that +already holds the plaintext, use value. The page encrypts each value to this key as a +compact JWE (alg ECDH-ES, enc A256GCM, kid in the protected header), and your backend +forwards the ciphertext unchanged as encrypted_value in credentials create/update specs. +Each vault has its own key; it is created on first request and may be cached.`, + Example: " kernel vaults get-encryption-key user-vault -o json", + RunE: func(cmd *cobra.Command, args []string) error { + return getVaultsHandler(cmd).GetEncryptionKey(cmd.Context(), args[0], vaultOutput(cmd)) + }} + addJSONOutputFlag(encryptionKey) + cmd.AddCommand(create, list, get, encryptionKey, newVaultDeleteCommand(false)) 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, diff --git a/cmd/vaults_credentials.go b/cmd/vaults_credentials.go index d6f5d70e..b328f35b 100644 --- a/cmd/vaults_credentials.go +++ b/cmd/vaults_credentials.go @@ -107,6 +107,8 @@ user-facing collection form renders that order unchanged. Definitions accept a s name and an optional non-secret human-readable label; forms fall back to name. Updates, state, and browser fills always use name. Field types are text, email, password, and totp; definitions also accept required, sensitive, and value. +Custom collection web apps that encrypt in the browser may send encrypted_value (a +compact JWE to the key from vaults get-encryption-key) instead of value; never both. Set description to the recognizable site name only, e.g. "Hacker News", not "Hacker News sign-in credentials". This text is the user-facing form title. Set sensitive:false explicitly for ordinary usernames and email addresses. @@ -155,7 +157,7 @@ func newVaultCredentialsCommand() *cobra.Command { }, } if update { - cmd.Long += "\nUpdate applies to Kernel-hosted credentials only. Update spec fields are an object keyed by field name, not the ordered array used on create.\nUpdate preserves omitted fields, replaces nonempty string values, and clears supported values with null or an empty string. Clearing a required text/email/password field returns pending_collection; form submissions still require a nonempty value.\nField definitions are immutable. Do not automatically retry version conflicts." + cmd.Long += "\nUpdate applies to Kernel-hosted credentials only. Update spec fields are an object keyed by field name, not the ordered array used on create.\nUpdate preserves omitted fields, replaces nonempty string values (value, or encrypted_value from a browser-encrypting collection app), and clears supported values with null or an empty string. Clearing a required text/email/password field returns pending_collection; form submissions still require a nonempty value.\nField definitions are immutable. Do not automatically retry version conflicts." cmd.Flags().Int64("version", 0, "Expected version from items get (required; never auto-refreshed)") _ = cmd.MarkFlagRequired("version") cmd.Flags().String("expected-item-id", "", "Immutable item ID from the original read; reject an update if the key now refers to a replacement item") diff --git a/cmd/vaults_output.go b/cmd/vaults_output.go index 2dfb6d53..50c10877 100644 --- a/cmd/vaults_output.go +++ b/cmd/vaults_output.go @@ -235,6 +235,18 @@ func printVault(v *kernel.Vault, output string) error { return nil } +// The encryption key is public, so JSON output passes the API response through unchanged. +func printVaultEncryptionKey(k *kernel.VaultEncryptionKey, output string) error { + if output == "json" { + return printVaultJSON(json.RawMessage(k.RawJSON())) + } + PrintTableNoPad(pterm.TableData{ + {"Property", "Value"}, {"Key ID (kid)", k.Kid}, {"Algorithm (alg)", string(k.Alg)}, {"Encryption (enc)", string(k.Enc)}, + {"JWK kty", k.Jwk.Kty}, {"JWK crv", k.Jwk.Crv}, {"JWK x", k.Jwk.X}, {"JWK y", k.Jwk.Y}, + }, true) + return nil +} + func vaultDisplayURL(address string) bool { u, err := url.Parse(address) if err != nil || (u.Scheme != "https" && u.Scheme != "http") || u.Hostname() == "" || u.User != nil { diff --git a/go.mod b/go.mod index 47835329..30fe7453 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.121.1-0.20261009121157-454206ab83bc + github.com/kernel/kernel-go-sdk v0.122.1-0.20261009181557-f737b63ffd16 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 fbd5a13e..620be3c1 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.121.1-0.20261009121157-454206ab83bc h1:iIVpTV5HZMbbDp74IB/0z1Qjz9uzL9wpKJRZXoiqPEk= -github.com/kernel/kernel-go-sdk v0.121.1-0.20261009121157-454206ab83bc/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= +github.com/kernel/kernel-go-sdk v0.122.1-0.20261009181557-f737b63ffd16 h1:/cyzSOIuZRlBmYJyXFBE0A62g+BZ+R+UNxA7VmZOJqE= +github.com/kernel/kernel-go-sdk v0.122.1-0.20261009181557-f737b63ffd16/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=