diff --git a/cmd/api_keys.go b/cmd/api_keys.go index 33a3344a..019cfcea 100644 --- a/cmd/api_keys.go +++ b/cmd/api_keys.go @@ -152,8 +152,10 @@ func (c APIKeysCmd) List(ctx context.Context, in APIKeysListInput) error { params.SortBy = kernel.APIKeyListParamsSortByName case "expires_at": params.SortBy = kernel.APIKeyListParamsSortByExpiresAt + case "last_used_at": + params.SortBy = kernel.APIKeyListParamsSortByLastUsedAt default: - return fmt.Errorf("invalid --sort-by value: %s (must be 'created_at', 'name', or 'expires_at')", in.SortBy) + return fmt.Errorf("invalid --sort-by value: %s (must be 'created_at', 'name', 'expires_at', or 'last_used_at')", in.SortBy) } } if in.SortDirection != "" { @@ -188,7 +190,7 @@ func (c APIKeysCmd) List(ctx context.Context, in APIKeysListInput) error { // Only surface Deleted At when the filter can actually return deleted keys. showDeletedAt := in.IncludeDeleted || in.Status == "deleted" || in.Status == "all" - header := []string{"ID", "Name", "Scope", "Project", "Masked Key", "Expires At", "Created At"} + header := []string{"ID", "Name", "Scope", "Project", "Masked Key", "Expires At", "Last Used At", "Created At"} if showDeletedAt { header = append(header, "Deleted At") } @@ -201,6 +203,7 @@ func (c APIKeysCmd) List(ctx context.Context, in APIKeysListInput) error { formatAPIKeyProject(key), key.MaskedKey, formatAPIKeyExpiresAt(key), + formatAPIKeyLastUsedAt(key), util.FormatLocal(key.CreatedAt), } if showDeletedAt { @@ -356,6 +359,7 @@ func renderAPIKeyDetails(key *kernel.APIKey) { {"Masked Key", key.MaskedKey}, {"Created By", formatAPIKeyCreator(*key)}, {"Expires At", formatAPIKeyExpiresAt(*key)}, + {"Last Used At", formatAPIKeyLastUsedAt(*key)}, {"Created At", util.FormatLocal(key.CreatedAt)}, } PrintTableNoPad(rows, true) @@ -395,6 +399,13 @@ func formatAPIKeyExpiresAt(key kernel.APIKey) string { return util.FormatLocal(key.ExpiresAt) } +func formatAPIKeyLastUsedAt(key kernel.APIKey) string { + if !key.JSON.LastUsedAt.Valid() { + return "-" + } + return util.FormatLocal(key.LastUsedAt) +} + func getAPIKeysHandler(cmd *cobra.Command) APIKeysCmd { client := getKernelClient(cmd) return APIKeysCmd{apiKeys: &client.APIKeys} @@ -497,6 +508,7 @@ var apiKeysCreateCmd = &cobra.Command{ var apiKeysListCmd = &cobra.Command{ Use: "list", Short: "List API keys", + Long: "List API keys. Last-used timestamps are accurate to within about 20 minutes; '-' means no use has been recorded since tracking began.", Args: cobra.NoArgs, RunE: runAPIKeysList, } @@ -504,6 +516,7 @@ var apiKeysListCmd = &cobra.Command{ var apiKeysGetCmd = &cobra.Command{ Use: "get ", Short: "Get an API key", + Long: "Get an API key. The last-used timestamp is accurate to within about 20 minutes; '-' means no use has been recorded since tracking began.", Args: cobra.ExactArgs(1), RunE: runAPIKeysGet, } @@ -544,7 +557,7 @@ func init() { apiKeysListCmd.Flags().String("query", "", "Search API keys by name, creator, or project (identifiers and masked keys match by exact value or prefix)") apiKeysListCmd.Flags().String("status", "", "Filter by status: 'active' (default), 'deleted', or 'all'") apiKeysListCmd.Flags().Bool("include-deleted", false, "Deprecated: Use --status all instead. Include soft-deleted API keys in the results") - apiKeysListCmd.Flags().String("sort-by", "", "Sort by: created_at, name, or expires_at") + apiKeysListCmd.Flags().String("sort-by", "", "Sort by: created_at, name, expires_at, or last_used_at. Keys without an expiry sort last for expires_at; keys without recorded use sort last for last_used_at, in either direction") apiKeysListCmd.Flags().String("sort-direction", "", "Sort direction: asc or desc") addJSONOutputFlag(apiKeysGetCmd) diff --git a/cmd/api_keys_test.go b/cmd/api_keys_test.go index 23d12167..9e1ae858 100644 --- a/cmd/api_keys_test.go +++ b/cmd/api_keys_test.go @@ -7,6 +7,7 @@ import ( "net/http" "testing" + "github.com/kernel/cli/pkg/util" "github.com/kernel/kernel-go-sdk" "github.com/kernel/kernel-go-sdk/option" "github.com/kernel/kernel-go-sdk/packages/pagination" @@ -183,7 +184,7 @@ func TestAPIKeysRejectInvalidOutputBeforeCallingAPI(t *testing.T) { func TestAPIKeysListPassesPaginationAndRendersRows(t *testing.T) { buf := capturePtermOutput(t) - key := *apiKeyFromJSON(`{"id":"key_123","name":"ci","masked_key":"sk_...123","created_at":"2026-05-27T12:00:00Z","created_by":{"id":"user_123","email":"dev@example.com","name":"Dev"},"expires_at":null,"project_id":null,"project_name":null}`) + key := *apiKeyFromJSON(`{"id":"key_123","name":"ci","masked_key":"sk_...123","created_at":"2026-05-27T12:00:00Z","created_by":{"id":"user_123","email":"dev@example.com","name":"Dev"},"expires_at":null,"last_used_at":"2026-05-28T13:30:00Z","project_id":null,"project_name":null}`) fake := &FakeAPIKeysService{ ListFunc: func(ctx context.Context, query kernel.APIKeyListParams, opts ...option.RequestOption) (*pagination.OffsetPagination[kernel.APIKey], error) { assert.True(t, query.Limit.Valid()) @@ -202,6 +203,27 @@ func TestAPIKeysListPassesPaginationAndRendersRows(t *testing.T) { assert.Contains(t, out, "key_123") assert.Contains(t, out, "ci") assert.Contains(t, out, "Never") + assert.Contains(t, out, "Last Used At") + assert.Contains(t, out, util.FormatLocal(key.LastUsedAt)) +} + +func TestAPIKeysListSupportsLastUsedAtSort(t *testing.T) { + capturePtermOutput(t) + fake := &FakeAPIKeysService{ + ListFunc: func(ctx context.Context, query kernel.APIKeyListParams, opts ...option.RequestOption) (*pagination.OffsetPagination[kernel.APIKey], error) { + assert.Equal(t, kernel.APIKeyListParamsSortByLastUsedAt, query.SortBy) + return &pagination.OffsetPagination[kernel.APIKey]{Items: []kernel.APIKey{}}, nil + }, + } + c := APIKeysCmd{apiKeys: fake} + + err := c.List(context.Background(), APIKeysListInput{SortBy: "last_used_at"}) + require.NoError(t, err) +} + +func TestFormatAPIKeyLastUsedAtHandlesUnrecordedUse(t *testing.T) { + key := apiKeyFromJSON(`{"last_used_at":null}`) + assert.Equal(t, "-", formatAPIKeyLastUsedAt(*key)) } func TestAPIKeysUpdateRequiresName(t *testing.T) { @@ -338,12 +360,13 @@ func TestAPIKeysRotateOmitsUnsetParams(t *testing.T) { } func TestAPIKeysGetPassesIncludeDeleted(t *testing.T) { - capturePtermOutput(t) + buf := capturePtermOutput(t) + key := apiKeyFromJSON(`{"id":"key_123","name":"default","masked_key":"sk_...test","created_at":"2026-05-27T12:00:00Z","created_by":{"id":"user_123","email":"dev@example.com","name":"Dev"},"expires_at":null,"last_used_at":"2026-05-28T13:30:00Z","project_id":null,"project_name":null}`) var captured kernel.APIKeyGetParams fake := &FakeAPIKeysService{ GetFunc: func(ctx context.Context, id string, query kernel.APIKeyGetParams, opts ...option.RequestOption) (*kernel.APIKey, error) { captured = query - return apiKeyFromJSON(`{"id":"` + id + `","name":"default","masked_key":"sk_...test","created_at":"2026-05-27T12:00:00Z","created_by":{"id":"user_123","email":"dev@example.com","name":"Dev"},"expires_at":null,"project_id":null,"project_name":null}`), nil + return key, nil }, } c := APIKeysCmd{apiKeys: fake} @@ -351,4 +374,6 @@ func TestAPIKeysGetPassesIncludeDeleted(t *testing.T) { require.NoError(t, c.Get(context.Background(), APIKeysGetInput{ID: "key_123", IncludeDeleted: true})) assert.True(t, captured.IncludeDeleted.Valid()) assert.True(t, captured.IncludeDeleted.Value) + assert.Contains(t, buf.String(), "Last Used At") + assert.Contains(t, buf.String(), util.FormatLocal(key.LastUsedAt)) } diff --git a/cmd/browsers.go b/cmd/browsers.go index 8aa800e0..4b2cd7bd 100644 --- a/cmd/browsers.go +++ b/cmd/browsers.go @@ -42,6 +42,7 @@ type BrowsersService interface { HTTPClient(id string, opts ...option.RequestOption) (*http.Client, error) LoadExtensions(ctx context.Context, idOrName string, body kernel.BrowserLoadExtensionsParams, opts ...option.RequestOption) (err error) Repl(ctx context.Context, idOrName string, body kernel.BrowserReplParams, opts ...option.RequestOption) (res *kernel.BrowserReplResult, err error) + SaveProfile(ctx context.Context, idOrName string, body kernel.BrowserSaveProfileParams, opts ...option.RequestOption) (res *kernel.Profile, err error) } // BrowserReplaysService defines the subset we use for browser replays. @@ -561,6 +562,13 @@ type BrowsersGetInput struct { Output string } +type BrowsersSaveProfileInput struct { + Identifier string + ProfileID string + ProfileName string + Output string +} + type BrowsersUpdateInput struct { Identifier string ProxyID string @@ -1084,6 +1092,60 @@ func (b BrowsersCmd) Get(ctx context.Context, in BrowsersGetInput) error { return nil } +func (b BrowsersCmd) SaveProfile(ctx context.Context, in BrowsersSaveProfileInput) error { + if err := validateJSONOutput(in.Output); err != nil { + return err + } + if (in.ProfileID == "") == (in.ProfileName == "") { + return fmt.Errorf("specify exactly one of --profile-id or --profile-name") + } + + destination := kernel.SaveProfileRequestParam{} + if in.ProfileID != "" { + destination.ID = kernel.Opt(in.ProfileID) + } else { + destination.Name = kernel.Opt(in.ProfileName) + } + // A retry after a failed or timed-out save would start a second save and + // restart the browser again. + var res *http.Response + profile, err := b.browsers.SaveProfile(ctx, in.Identifier, kernel.BrowserSaveProfileParams{SaveProfileRequest: destination}, option.WithMaxRetries(0), option.WithResponseInto(&res)) + if err != nil { + return util.CleanedUpSdkError{Err: err} + } + if in.Output == "json" { + return util.PrintPrettyJSON(profile) + } + + name := profile.Name + if name == "" { + name = "-" + } + PrintTableNoPad(pterm.TableData{ + {"Property", "Value"}, + {"ID", profile.ID}, + {"Name", name}, + {"Created At", util.FormatLocal(profile.CreatedAt)}, + {"Updated At", util.FormatLocal(profile.UpdatedAt)}, + {"Last Used At", util.FormatLocal(profile.LastUsedAt)}, + }, true) + pterm.Success.Println(saveProfileOutcome(res)) + pterm.Info.Println("The browser restarted. Reconnect CDP clients; WebDriver clients must create a new session.") + return nil +} + +// saveProfileOutcome reports whether the save created the profile (201) or +// overwrote an existing one (200). +func saveProfileOutcome(res *http.Response) string { + if res != nil && res.StatusCode == http.StatusCreated { + return "Created profile" + } + if res != nil && res.StatusCode == http.StatusOK { + return "Overwrote profile" + } + return "Saved profile" +} + // browserUpdateProxySelection folds every proxy flag `browsers update` accepts // into the single proxy configuration the API takes. --clear-proxy drops a // selected proxy back to the browser default, and --disable-default-proxy @@ -3002,6 +3064,41 @@ var browsersGetCmd = &cobra.Command{ RunE: runBrowsersGet, } +var browsersSaveProfileCmd = &cobra.Command{ + Use: "save-profile ", + Short: "Save the running browser state to a profile", + Long: `Save the running browser's current user-data state to an existing or new profile. + +Provide exactly one destination: --profile-id overwrites an existing profile; +--profile-name overwrites a matching profile in the browser's project or creates it. + +Saving restarts the browser without ending the session. Active CDP (including Playwright) +and WebDriver connections close; reconnect CDP clients and create a new WebDriver session. +Tabs are restored, but pages reload and in-memory page state is lost, so save at a stable +point, such as right after a login completes. The command returns after +the restarted browser accepts CDP connections and the profile upload completes. Caches, +Service Worker storage, and Google Drive IndexedDB data are excluded from the saved +profile but remain in the running browser. + +This does not attach the destination profile to the browser or change save_changes. +Other active browsers that already loaded the destination keep their in-memory state. +Pools with refresh_on_profile_update flush idle sessions and replenish from this +snapshot; other browsers receive it the next time they load the profile. + +Of overlapping saves made by this command to the same profile, the most recently +started save wins. An automatic end-of-session save from save_changes is instead +ordered against these saves by completion time. A successful save restarts the browser +inactivity timeout; a failed save leaves it unchanged. + +A 409 can mean the browser is no longer running (browser_not_active), a pooled browser +was released or reacquired (browser_lease_changed), or it is briefly busy (browser_busy). +Retry only browser_busy, or resolve the current pooled-browser lease before making a new +explicit save. The command doesn't retry on its own; if it is interrupted, the save may +still finish, so check the profile before saving again.`, + Args: cobra.ExactArgs(1), + RunE: runBrowsersSaveProfile, +} + var browsersUpdateCmd = &cobra.Command{ Use: "update ", Short: "Update a browser session by ID or name", @@ -3069,6 +3166,13 @@ func init() { // view flags addJSONOutputFlag(browsersViewCmd) + // save-profile flags + addJSONOutputFlag(browsersSaveProfileCmd) + browsersSaveProfileCmd.Flags().String("profile-id", "", "Existing profile ID to overwrite (mutually exclusive with --profile-name)") + browsersSaveProfileCmd.Flags().String("profile-name", "", "Profile name to overwrite or create (mutually exclusive with --profile-id)") + browsersSaveProfileCmd.MarkFlagsOneRequired("profile-id", "profile-name") + browsersSaveProfileCmd.MarkFlagsMutuallyExclusive("profile-id", "profile-name") + // update flags addJSONOutputFlag(browsersUpdateCmd) browsersUpdateCmd.Flags().String("proxy-id", "", "ID of the proxy to use for the browser session (mutually exclusive with --proxy-name and --proxy-mode)") @@ -3100,6 +3204,7 @@ func init() { browsersCmd.AddCommand(browsersDeleteCmd) browsersCmd.AddCommand(browsersViewCmd) browsersCmd.AddCommand(browsersGetCmd) + browsersCmd.AddCommand(browsersSaveProfileCmd) browsersCmd.AddCommand(browsersUpdateCmd) // ssh @@ -3753,6 +3858,21 @@ func runBrowsersGet(cmd *cobra.Command, args []string) error { }) } +func runBrowsersSaveProfile(cmd *cobra.Command, args []string) error { + client := getKernelClient(cmd) + output, _ := cmd.Flags().GetString("output") + profileID, _ := cmd.Flags().GetString("profile-id") + profileName, _ := cmd.Flags().GetString("profile-name") + + svc := client.Browsers + return (BrowsersCmd{browsers: &svc}).SaveProfile(cmd.Context(), BrowsersSaveProfileInput{ + Identifier: args[0], + ProfileID: profileID, + ProfileName: profileName, + Output: output, + }) +} + func runBrowsersUpdate(cmd *cobra.Command, args []string) error { client := getKernelClient(cmd) out, _ := cmd.Flags().GetString("output") diff --git a/cmd/browsers_test.go b/cmd/browsers_test.go index c6b4fa64..ed3abefa 100644 --- a/cmd/browsers_test.go +++ b/cmd/browsers_test.go @@ -67,6 +67,7 @@ type FakeBrowsersService struct { HTTPClientFunc func(id string, opts ...option.RequestOption) (*http.Client, error) LoadExtensionsFunc func(ctx context.Context, id string, body kernel.BrowserLoadExtensionsParams, opts ...option.RequestOption) error ReplFunc func(ctx context.Context, id string, body kernel.BrowserReplParams, opts ...option.RequestOption) (*kernel.BrowserReplResult, error) + SaveProfileFunc func(ctx context.Context, id string, body kernel.BrowserSaveProfileParams, opts ...option.RequestOption) (*kernel.Profile, error) } func (f *FakeBrowsersService) Get(ctx context.Context, id string, query kernel.BrowserGetParams, opts ...option.RequestOption) (*kernel.BrowserGetResponse, error) { @@ -125,6 +126,73 @@ func (f *FakeBrowsersService) Repl(ctx context.Context, id string, body kernel.B return &kernel.BrowserReplResult{}, nil } +func (f *FakeBrowsersService) SaveProfile(ctx context.Context, id string, body kernel.BrowserSaveProfileParams, opts ...option.RequestOption) (*kernel.Profile, error) { + if f.SaveProfileFunc != nil { + return f.SaveProfileFunc(ctx, id, body, opts...) + } + return &kernel.Profile{}, nil +} + +func TestBrowsersSaveProfile(t *testing.T) { + created := time.Date(2026, 9, 1, 12, 0, 0, 0, time.UTC) + for _, tc := range []struct { + name string + input BrowsersSaveProfileInput + expected string + }{ + {name: "existing id", input: BrowsersSaveProfileInput{Identifier: "browser-1", ProfileID: "profile-1", Output: "json"}, expected: `{"id":"profile-1"}`}, + {name: "new name", input: BrowsersSaveProfileInput{Identifier: "browser-1", ProfileName: "checkout", Output: "json"}, expected: `{"name":"checkout"}`}, + } { + t.Run(tc.name, func(t *testing.T) { + calls := 0 + fake := &FakeBrowsersService{SaveProfileFunc: func(ctx context.Context, id string, body kernel.BrowserSaveProfileParams, opts ...option.RequestOption) (*kernel.Profile, error) { + calls++ + assert.Equal(t, "browser-1", id) + assert.Len(t, opts, 2, "save-profile disables retries and captures the response") + raw, err := json.Marshal(body) + require.NoError(t, err) + assert.JSONEq(t, tc.expected, string(raw)) + var profile kernel.Profile + require.NoError(t, json.Unmarshal([]byte(fmt.Sprintf(`{"id":"profile-1","name":"checkout","created_at":%q,"updated_at":%q,"last_used_at":%q}`, created.Format(time.RFC3339), created.Format(time.RFC3339), created.Format(time.RFC3339))), &profile)) + return &profile, nil + }} + out := captureStdout(t, func() { + require.NoError(t, (BrowsersCmd{browsers: fake}).SaveProfile(context.Background(), tc.input)) + }) + assert.Equal(t, 1, calls) + assert.Contains(t, out, `"id": "profile-1"`) + }) + } +} + +func TestBrowsersSaveProfileValidationAndHelp(t *testing.T) { + fake := &FakeBrowsersService{SaveProfileFunc: func(ctx context.Context, id string, body kernel.BrowserSaveProfileParams, opts ...option.RequestOption) (*kernel.Profile, error) { + t.Fatal("invalid destination reached API") + return nil, nil + }} + b := BrowsersCmd{browsers: fake} + for _, input := range []BrowsersSaveProfileInput{ + {Identifier: "browser-1"}, + {Identifier: "browser-1", ProfileID: "profile-1", ProfileName: "checkout"}, + } { + require.ErrorContains(t, b.SaveProfile(context.Background(), input), "exactly one") + } + + cmd, _, err := rootCmd.Find([]string{"browsers", "save-profile"}) + require.NoError(t, err) + assert.NotNil(t, cmd.Flags().Lookup("profile-id")) + assert.NotNil(t, cmd.Flags().Lookup("profile-name")) + for _, text := range []string{"Saving restarts the browser", "CDP", "Playwright", "WebDriver", "pages reload", "stable", "profile upload completes", "remain in the running browser", "does not attach", "save_changes", "refresh_on_profile_update", "completion time", "failed save leaves it unchanged", "browser_busy", "browser_lease_changed", "browser_not_active", "doesn't retry"} { + assert.Contains(t, cmd.Long, text) + } +} + +func TestSaveProfileOutcome(t *testing.T) { + assert.Equal(t, "Created profile", saveProfileOutcome(&http.Response{StatusCode: http.StatusCreated})) + assert.Equal(t, "Overwrote profile", saveProfileOutcome(&http.Response{StatusCode: http.StatusOK})) + assert.Equal(t, "Saved profile", saveProfileOutcome(nil)) +} + func TestBrowsersCurlRawUsesBrowserHTTPClient(t *testing.T) { var ( gotMethod string diff --git a/cmd/vault_provider_configs.go b/cmd/vault_provider_configs.go index ca18d59d..2ecd237e 100644 --- a/cmd/vault_provider_configs.go +++ b/cmd/vault_provider_configs.go @@ -79,7 +79,7 @@ Secrets are accepted only from a file or stdin and are never displayed. Protect params.OfAgentcard = &kernel.VaultProviderConfigNewParamsBodyAgentcard{Name: name, Credentials: kernel.VaultProviderConfigNewParamsBodyAgentcardCredentials{ClientID: credentials["client_id"], ClientSecret: credentials["client_secret"]}} } c := getVaultProviderConfigsHandler(cmd) - config, err := c.configs.New(cmd.Context(), params, option.WithMaxRetries(0)) + config, err := c.configs.New(cmd.Context(), params, vaultWriteRequestOptions()...) if err != nil { return vaultCredentialError(err) } @@ -144,7 +144,7 @@ Secrets are accepted only from a file or stdin and are never displayed. Protect params.Credentials.PublishableKey = kernel.Opt(publishableKey) } c := getVaultProviderConfigsHandler(cmd) - config, err := c.configs.Update(cmd.Context(), args[0], params, option.WithMaxRetries(0)) + config, err := c.configs.Update(cmd.Context(), args[0], params, vaultWriteRequestOptions()...) if err != nil { return vaultCredentialError(err) } @@ -170,7 +170,7 @@ Secrets are accepted only from a file or stdin and are never displayed. Protect return nil } } - err := c.configs.Delete(cmd.Context(), args[0], option.WithMaxRetries(0)) + err := c.configs.Delete(cmd.Context(), args[0], vaultWriteRequestOptions()...) if err != nil { return vaultCredentialError(err) } diff --git a/cmd/vaults.go b/cmd/vaults.go index 91495c55..a0b4507a 100644 --- a/cmd/vaults.go +++ b/cmd/vaults.go @@ -36,7 +36,7 @@ func (c VaultsCmd) Create(ctx context.Context, name, output string) error { if err := validateVaultName(name, "--name"); err != nil { return err } - v, err := c.vaults.Upsert(ctx, kernel.VaultUpsertParams{Name: name}, option.WithMaxRetries(0)) + v, err := c.vaults.Upsert(ctx, kernel.VaultUpsertParams{Name: name}, vaultWriteRequestOptions()...) if err != nil { return util.CleanedUpSdkError{Err: err} } @@ -126,9 +126,9 @@ func (c VaultsCmd) Delete(ctx context.Context, vault, key string, yes bool) erro } var err error if key == "" { - err = c.vaults.Delete(ctx, vault, option.WithMaxRetries(0)) + err = c.vaults.Delete(ctx, vault, vaultWriteRequestOptions()...) } else { - err = c.vaults.Items.Delete(ctx, key, kernel.VaultItemDeleteParams{IDOrName: vault}, option.WithMaxRetries(0)) + err = c.vaults.Items.Delete(ctx, key, kernel.VaultItemDeleteParams{IDOrName: vault}, vaultWriteRequestOptions()...) } if err != nil && !util.IsNotFound(err) { return util.CleanedUpSdkError{Err: err} @@ -234,7 +234,7 @@ func (c VaultsCmd) GetItem(ctx context.Context, vault, key string, wait int64, e } func (c VaultsCmd) CreateWallet(ctx context.Context, vault, key string, spec kernel.VaultItemUpsertParamsBodyWalletSpecUnion, output string, open bool) error { - item, err := c.vaults.Items.Upsert(ctx, key, kernel.VaultItemUpsertParams{IDOrName: vault, OfWallet: &kernel.VaultItemUpsertParamsBodyWallet{Spec: spec}}, option.WithMaxRetries(0)) + item, err := c.vaults.Items.Upsert(ctx, key, kernel.VaultItemUpsertParams{IDOrName: vault, OfWallet: &kernel.VaultItemUpsertParamsBodyWallet{Spec: spec}}, vaultWriteRequestOptions()...) if err != nil { if vaultSpecProvider(spec) == "kernel" { return kernelVaultRequestError(err, "create") @@ -248,9 +248,9 @@ func (c VaultsCmd) SaveCard(ctx context.Context, vault, key string, spec kernel. var item *kernel.VaultItemUnion var err error if update { - item, err = c.vaults.Items.Update(ctx, key, kernel.VaultItemUpdateParams{IDOrName: vault, OfCardVaultItemUpdateRequest: &kernel.VaultItemUpdateParamsBodyCardVaultItemUpdateRequest{Type: "card", Spec: spec}}, option.WithMaxRetries(0)) + item, err = c.vaults.Items.Update(ctx, key, kernel.VaultItemUpdateParams{IDOrName: vault, OfCardVaultItemUpdateRequest: &kernel.VaultItemUpdateParamsBodyCardVaultItemUpdateRequest{Type: "card", Spec: spec}}, vaultWriteRequestOptions()...) } else { - item, err = c.vaults.Items.Upsert(ctx, key, kernel.VaultItemUpsertParams{IDOrName: vault, OfCard: &kernel.VaultItemUpsertParamsBodyCard{Spec: spec}}, option.WithMaxRetries(0)) + item, err = c.vaults.Items.Upsert(ctx, key, kernel.VaultItemUpsertParams{IDOrName: vault, OfCard: &kernel.VaultItemUpsertParamsBodyCard{Spec: spec}}, vaultWriteRequestOptions()...) } if err != nil { if vaultSpecProvider(spec) == "kernel" { @@ -261,6 +261,36 @@ func (c VaultsCmd) SaveCard(ctx context.Context, vault, key string, spec kernel. return c.showItem(item, output, false) } +func (c VaultsCmd) SavePaymentRequest(ctx context.Context, vault, key string, spec kernel.PaymentRequestVaultItemSpecUnionParam, update bool, output string) error { + var item *kernel.VaultItemUnion + var err error + if update { + item, err = c.vaults.Items.Update(ctx, key, kernel.VaultItemUpdateParams{ + IDOrName: vault, + OfPaymentRequestVaultItemUpdateRequest: &kernel.VaultItemUpdateParamsBodyPaymentRequestVaultItemUpdateRequest{ + Type: "payment_request", + Spec: spec, + }, + }, vaultWriteRequestOptions()...) + } else { + item, err = c.vaults.Items.Upsert(ctx, key, kernel.VaultItemUpsertParams{ + IDOrName: vault, + OfPaymentRequest: &kernel.VaultItemUpsertParamsBodyPaymentRequest{Spec: spec}, + }, vaultWriteRequestOptions()...) + } + if err != nil { + if vaultSpecProvider(spec) == "kernel" { + operation := "create" + if update { + operation = "update" + } + return kernelVaultRequestError(err, operation) + } + return util.CleanedUpSdkError{Err: err} + } + return c.showItem(item, output, false) +} + func (c VaultsCmd) Invoke(ctx context.Context, vault, key, operation string, params *vaultOperationParams, output string, open bool) error { if strings.TrimSpace(operation) == "" { return fmt.Errorf("operation must not be empty") @@ -283,6 +313,9 @@ func (c VaultsCmd) Invoke(ctx context.Context, vault, key, operation string, par if isOnePasswordOperation(operation) && (params == nil || params.OnePassword == nil) { return fmt.Errorf("%s requires its documented parameters", operation) } + if (operation == "add_payment_method" || operation == "remove_payment_method") && (params == nil || params.PaymentMethod == 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" || operation == "webmcp_invoke" { @@ -325,13 +358,13 @@ func (c VaultsCmd) Invoke(ctx context.Context, vault, key, operation string, par return fmt.Errorf("confirm_transaction currency must match the approved purchase currency and amount must not exceed the approved purchase amount; operation was not invoked") } } - linkCard := item.Type == "card" && item.Spec.Provider == "link" - if params != nil && params.Authorize != nil && !linkCard { - return fmt.Errorf("--browser-id and --page-url only apply to Link cards; authorize was not invoked") + linkPurchase := (item.Type == "card" || item.Type == "payment_request") && item.Spec.Provider == "link" + if params != nil && params.Authorize != nil && !linkPurchase { + return fmt.Errorf("--browser-id and --page-url only apply to Link cards or Link payment_request items; authorize was not invoked") } if operation == "fill" { - if !linkCard && len(params.Fill.Fields) == 0 { - return fmt.Errorf("fill for this item requires 1-32 fields binding field names to selectors; only a Link card's fill may omit them. fill was not invoked") + if !linkPurchase && len(params.Fill.Fields) == 0 { + return fmt.Errorf("fill for this item requires 1-32 fields binding field names to selectors; only a Link card's fill may omit them, including the equivalent Link payment_request fill. fill was not invoked") } return c.fill(ctx, vault, key, params.Fill, output, item.Spec.Provider) } @@ -342,7 +375,7 @@ func (c VaultsCmd) Invoke(ctx context.Context, vault, key, operation string, par return c.webMCPInvoke(ctx, vault, key, params.WebMCP, output) } request := kernel.VaultItemPerformOperationParams{IDOrName: vault} - opts := []option.RequestOption{option.WithMaxRetries(0)} + opts := vaultWriteRequestOptions() if operation == "confirm_transaction" { // Replace with a typed SDK parameter once the operation is generated. body, err := json.Marshal(params.ConfirmTransaction) @@ -356,6 +389,9 @@ func (c VaultsCmd) Invoke(ctx context.Context, vault, key, operation string, par } else if params != nil && params.OnePassword != nil { request = *params.OnePassword request.IDOrName = vault + } else if params != nil && params.PaymentMethod != nil { + request = *params.PaymentMethod + request.IDOrName = vault } else if operation == "prepare_checkout" { if item.Type != "card" || item.Spec.Provider != "agentcard" { return fmt.Errorf("prepare_checkout requires an AgentCard card") @@ -380,7 +416,7 @@ func (c VaultsCmd) Invoke(ctx context.Context, vault, key, operation string, par if item.Spec.Provider == "kernel" { return kernelVaultRequestError(err, operation) } - if operation == "authorize" && linkCard { + if operation == "authorize" && linkPurchase { return linkAuthorizeError(err, params != nil && params.Authorize != nil) } return util.CleanedUpSdkError{Err: err} @@ -388,7 +424,10 @@ func (c VaultsCmd) Invoke(ctx context.Context, vault, key, operation string, par if isPasskeyOperation(operation) { return printVaultPasskeyOperationResult(response, operation, output) } - if response == nil || (response.Type != "card" && response.Type != "wallet" && response.Type != "credential" && response.Type != "credential_account") { + if operation == "add_payment_method" || operation == "remove_payment_method" { + return printVaultPaymentMethodOperationResult(response, operation, output) + } + if response == nil || (response.Type != "card" && response.Type != "payment_request" && response.Type != "wallet" && response.Type != "credential" && response.Type != "credential_account") { if item.Spec.Provider == "kernel" { return fmt.Errorf("unexpected vault operation response; %s", kernelVaultRetryGuidance(operation)) } diff --git a/cmd/vaults_commands.go b/cmd/vaults_commands.go index 0e958b43..42306604 100644 --- a/cmd/vaults_commands.go +++ b/cmd/vaults_commands.go @@ -55,7 +55,8 @@ func newVaultsCommand() *cobra.Command { Long: `collect user credentials and manage payment credentials. do not use credential items to store, collect, or fill credit card data. -use wallet and card item types for credit cards and payment checkout instead. +use wallet and card item types for credit cards and payment checkout instead; where enabled, +use payment_request items for one Link or KERNEL purchase. ` + vaultCredentialPathsHelp + ` @@ -87,36 +88,41 @@ vault names, item keys, and project ownership are immutable. 1. create/select a vault, then create a provider wallet and follow its returned action. for kernel, share the hosted card_enrollment url; card details stay on that page. -2. inspect wallets payment-methods for eligibility and reasons before creating a card. +2. inspect wallets payment-methods for eligibility and reasons before creating a purchase. for Link, select a payment method id explicitly. 3. attach the vault with browsers create --vault ; attachment is required for - authorizing a Link card at a checkout and for fill. for Link, reach the final checkout - page before creating the card. -4. create a card request with --provider and --spec json. + authorizing a Link card at a checkout and for fill; the same applies to a Link + payment_request. for Link, reach the final checkout + page before creating the purchase. +4. create a purchase with cards create, or where enabled use payment-requests create + for a Link or KERNEL purchase. pass --provider and --spec json. 5. inspect items get, then use items invoke only when advertised. follow the operation description and any returned provider action. -6. ready Link and kernel cards use only advertised fill with --params for browser checkout. - neither exposes aliases or supports egress substitution. kernel fill is locked to - merchant_url's origin; submit the merchant checkout before the card expires. - kernel cards have no stored billing fields; enter billing details on the merchant page. +6. ready Link and kernel cards use only advertised fill with --params for browser checkout; + equivalent payment_request items do too. neither exposes aliases or supports egress + substitution. kernel fill is locked to merchant_url's origin; submit the merchant + checkout before the purchase expires. + KERNEL card and payment_request purchases have no stored billing fields; enter billing + details on the merchant page. AgentCard-only checkout aliases support egress substitution with checkout hold, approval, and replay; they are not a fallback after fill. -7. after the user submits kernel card checkout, report the observed outcome once with - confirm_transaction when advertised. ` + kernelTransactionHelp + `. +7. after the user submits KERNEL card or payment_request checkout, report the observed + outcome once with confirm_transaction when advertised. ` + kernelTransactionHelp + `. in recovery_required, ` + vaultRecoveryHelp + ` ` + linkCardFlowHelp + ` -permitted checkout domains are displayed when returned. Link card fill is limited to the -origin of the checkout it was authorized at (or merchant_url's origin for a virtual card -authorized without a checkout); kernel card fill uses the exact origin of merchant_url; +permitted checkout domains are displayed when returned. Link card and payment_request fill +is limited to the origin where the purchase was authorized (or merchant_url's origin for a +virtual card authorized without a checkout); kernel card and payment_request fill uses the +exact origin of merchant_url; AgentCard domains are provider-assigned. there is no domain-setting api. never supply card data, OAuth codes, ciphertext, or secrets in shell arguments. use vault-provider-configs for client credentials and wallets create --tokens-file for imported Link grants; both accept protected files or stdin. do not automatically repeat failed, timed-out, rejected, or indeterminate payments: inspect the item, its events, and the merchant's order history, then follow the advertised operations. -when an item explicitly permits user-confirmed abandonment, delete that card before creating a replacement. +when an item explicitly permits user-confirmed abandonment, delete that purchase item before creating a replacement. json output preserves returned public fields but omits unknown/opaque provider data.`, Run: func(cmd *cobra.Command, args []string) { _ = cmd.Help() }, } @@ -166,7 +172,7 @@ each vault has its own key; it is created on first request and may be cached.`, addJSONOutputFlag(encryptionKey) cmd.AddCommand(create, list, get, encryptionKey, newVaultDeleteCommand(false)) - items := &cobra.Command{Use: "items", Short: "inspect item readiness or invoke advertised credential and payment operations", Long: "use get --wait 60 to observe readiness and get -o json for schema/version/presence.\nuse invoke collect to obtain a collection url, invoke fill --spec-file to fill a browser, or invoke advertised passkey operations with --browser-id.\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."} + items := &cobra.Command{Use: "items", Short: "inspect item readiness or invoke advertised credential and payment operations", Long: "use get --wait 60 to observe readiness and get -o json for schema/version/presence.\nuse invoke collect to obtain a collection url, invoke fill --spec-file to fill a browser, or invoke advertised passkey operations with --browser-id.\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/payment-requests."} 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)) @@ -198,27 +204,29 @@ each vault has its own key; it is created on first request and may be cached.`, invoke := &cobra.Command{Use: "invoke ", Short: "invoke an operation advertised by an item", Args: cobra.ExactArgs(3), PreRunE: vaultPreRun, Long: `retrieve the item and invoke only an operation listed in available_operations. collect returns a time-scoped url for the full credential form without clearing values. -authorize sends {"type":"authorize"} for payment authorization. for a Link card at the final -checkout page, add --browser-id and --page-url (both or neither) to send browser_id and -page_url; see Link card checkout below. +authorize sends {"type":"authorize"} for payment authorization. for a Link card or +payment_request at the final checkout page, add --browser-id and --page-url (both or +neither) to send browser_id and page_url; see Link checkout below. read the operation description and follow any approval requirements before invoking. fill requires --params json or --spec-file with browser_id (session id, not name) and the inputs the item's advertised fill description asks for: 1-32 ordered fields -(field, selector) for credentials, kernel cards, and Link virtual cards; no fields for a -Link Pay Token. do not include type, values, or frame ids. +(field, selector) for credentials, kernel card/payment_request items, and Link virtual +cards/payment requests; no fields for a Link Pay Token. do not include type, values, or frame ids. the vault must already be attached to the browser. page_url selects an existing page; fill never navigates. credentials use declared field names, must omit format, and may omit page_url only when the api can resolve a unique page. totp codes stay server-generated. -cards require an exact https page_url. Link virtual card fields: number, cvc, exp_month (MM), +payment card and payment_request items require an exact https page_url. Link virtual card fields: number, cvc, exp_month (MM), exp_year (YYYY), billing_name, billing_line1, billing_line2, billing_city, billing_state, billing_postal_code, billing_country. expiration requires format MM/YY or MM/YYYY. deprecated timeout_ms is accepted for compatibility but ignored; fill sets no operation deadline and adapts typing pace to value length. -kernel cards support number, expiration (format MM/YY or MM/YYYY), cvc, exp_month, and exp_year. -kernel cards have no stored billing fields: billing_* returns field_unavailable (400) before +kernel card and payment_request items support number, expiration (format MM/YY or MM/YYYY), cvc, exp_month, and exp_year. +kernel cards have no stored billing fields; kernel payment_request items have the same boundary: billing_* returns field_unavailable (400) before any write. enter billing details on the merchant page. the api searches the page and descendant frames, including payment iframes. fill is available for credential items and ready Link or kernel cards when advertised, not AgentCard. -Link and kernel cards do not expose aliases or support egress substitution. kernel card fill +equivalent Link and kernel payment_request items also use fill when advertised. +Link and kernel cards do not expose aliases or support egress substitution; payment_request +items have the same boundary. kernel fill is locked to merchant_url's origin and its one-time code expires; submit checkout before expiry. fill writes real values into the browser; unrestricted browser/cdp access can read them. fill never explicitly submits forms or clicks buttons, but input/change events may trigger site behavior. @@ -247,6 +255,15 @@ AUTHORIZATION, CAPTURE, REFUND, REVERSAL, VERIFICATION, CHARGEBACK, FRAUD), amou (integer minor units 0..1000000, no greater than the approved amount), currency (three uppercase letters matching the approved purchase currency), and occurred_at (RFC3339). no other fields are accepted. ` + kernelTransactionHelp + `. +add_payment_method takes no parameters and is valid only when advertised by a connected +KERNEL wallet. each call creates a separate pending enrollment with a single-use url that +expires after 30 minutes; share it only with the cardholder and never retry automatically. +a failed or expired enrollment does not affect other methods. remove_payment_method +requires {"payment_method_id":""}. it blocks new purchases, fills, and confirmations +funded by that method without disconnecting the wallet or changing other methods. an +unresolved purchase can block removal; an uncertain provider outcome leaves removal_pending +and must be re-checked before an explicit retry. removal is not proof that a payment did +not occur. scoped keys need both vaults:use and vaults:write. restart_verification takes no parameters. in recovery_required, ` + vaultRecoveryHelp + ` it rotates the approval link without a provider call, only after a classified initial attestation failure with proof no intent was dispatched. other recovery states must not be retried. @@ -258,7 +275,7 @@ completed exits 0, failed/unknown exit nonzero with valid json retained on stdou ` + vaultPasskeyOperationHelp + ` -webmcp_invoke (credential items and ready Link cards when advertised; see items webmcp invoke --help +webmcp_invoke (credential items and ready Link card/payment_request items 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 @@ -278,14 +295,15 @@ and never retry automatically. 400/403/404/409 rejections mean the tool was not owner unchanged; invoke it once per credential. 1pw_access_request_status: no browser needed; optional timeout_seconds 0-120 (default 10). check status after the account owner has the approval link. -1pw_fill: browser_id and the exact page_url of one open login page on a requested - origin; entry_id when several approved entries share that origin; optional - timeout_ms 1-30000. the extension selects fields and submits. fill_submitted exits 0 +1pw_fill: browser_id and the exact page_url of an open login page on a requested + origin; target_id when several open pages have that exact url; entry_id when several + approved entries share that origin; optional timeout_ms 1-30000. the extension selects fields and submits. fill_submitted exits 0 and does not confirm sign-in; inspect the page. fill_failed and fill_unknown exit nonzero. 1pw_fill can submit the form, so never retry automatically. check the page first before retrying. 1pw_update_access_token (stored-token credentials only): --spec-file, never --params, - with access_token only. + with access_token and optional access_token_expires_at (RFC3339). omitting the + expiry clears the previously supplied expiry. 1pw_recover (credential accounts, no parameters): returns a new 1Password link that recovers a failed account connection. share it with the account owner, then run credentials connect again with the same key; never delete the item to recover. @@ -304,7 +322,9 @@ JSON kernel vaults items invoke user-vault github 1pw_create_access_request --params '{"reason":"Sign in to GitHub"}' kernel vaults items invoke user-vault github 1pw_access_request_status --params '{"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 github 1pw_fill --params '{"browser_id":"","page_url":"https://github.com/login","entry_id":"","target_id":""}' + kernel vaults items invoke checkout cardholder add_payment_method + kernel vaults items invoke checkout cardholder remove_payment_method --params '{"payment_method_id":""}' kernel vaults items invoke user-vault login register_passkey --browser-id --field passkey kernel vaults items invoke user-vault login arm_passkey --browser-id --field passkey kernel vaults items invoke user-vault login disarm_passkey --browser-id @@ -320,7 +340,7 @@ JSON } if cmd.Flags().Changed("spec-file") { if !vaultOperationTakesParams(args[2]) { - return fmt.Errorf("--spec-file is only supported for fill, webmcp_invoke, prepare_checkout, confirm_transaction, and 1Password operations with parameters; passkey operations use --browser-id and optional --field") + return fmt.Errorf("--spec-file is only supported for fill, webmcp_invoke, prepare_checkout, confirm_transaction, remove_payment_method, and 1Password operations with parameters; passkey operations use --browser-id and optional --field") } data, err := readVaultJSONFile(cmd, "spec-file") if err != nil { @@ -347,7 +367,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, webmcp_invoke, prepare_checkout, confirm_transaction, 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, confirm_transaction, remove_payment_method, 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; cardholder links are human-only, agents must share privately") @@ -359,7 +379,7 @@ JSON 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, - Long: "create a wallet at an immutable key and follow the returned provider action.\na connected Link wallet pays at checkout through cards; its returned description and the flow below describe how.\n" + vaultSpecHelp + vaultWalletSpecHelp + "\n" + linkCardFlowHelp, + Long: "create a wallet at an immutable key and follow the returned provider action.\na connected Link wallet pays at checkout through card or payment_request items; its returned description and the flow below describe how.\n" + vaultSpecHelp + vaultWalletSpecHelp + "\n" + linkCardFlowHelp, Example: ` kernel vaults wallets create checkout wallet-1 \ --provider link --spec '{ "authorization": { @@ -408,7 +428,9 @@ JSON cards := &cobra.Command{Use: "cards", Short: "configure card requests"} cards.AddCommand(newVaultCardCommand(false), newVaultCardCommand(true)) - cmd.AddCommand(items, wallets, cards, newVaultCredentialsCommand()) + paymentRequests := &cobra.Command{Use: "payment-requests", Short: "configure one-purchase Link or KERNEL payment requests"} + paymentRequests.AddCommand(newVaultPaymentRequestCommand(false), newVaultPaymentRequestCommand(true)) + cmd.AddCommand(items, wallets, cards, paymentRequests, newVaultCredentialsCommand()) return cmd } @@ -473,6 +495,50 @@ an AgentCard recovery item that permits abandonment must be deleted after explic return cmd } +func newVaultPaymentRequestCommand(update bool) *cobra.Command { + use, short := "create", "create a one-purchase payment request without authorizing it" + if update { + use, short = "update", "update a payment request spec before checkout authorization" + } + cmd := &cobra.Command{ + Use: use + " --provider --spec ''", + Short: short, + Args: cobra.ExactArgs(2), + PreRunE: vaultPreRun, + Long: short + `. payment_request is available only where enabled for the organization. +Link and KERNEL payment requests are parented by their wallet and otherwise follow the +same purchase lifecycle and advertised operations as the corresponding card item. +AgentCard reusable cards remain card items. identical creates retrieve existing state; +never create another request to retry an uncertain payment. + +` + linkCardFlowHelp + "\n" + vaultSpecHelp + vaultPaymentRequestSpecHelp, + Example: " kernel vaults payment-requests " + use + ` checkout order-1 \ + --provider kernel --spec '{ + "wallet": "cardholder", + "payment_method_id": "", + "amount": 1234, + "currency": "usd", + "merchant_name": "Example Shop", + "merchant_url": "https://shop.example/checkout", + "merchant_country": "US" + }'`, + RunE: func(cmd *cobra.Command, args []string) error { + spec, err := vaultSpecFromFlags(cmd) + if err != nil { + return err + } + if string(spec["provider"]) == `"agentcard"` { + return fmt.Errorf("payment_request items support only link or kernel; AgentCard reusable cards use vaults cards") + } + return getVaultsHandler(cmd).SavePaymentRequest(cmd.Context(), args[0], args[1], param.Override[kernel.PaymentRequestVaultItemSpecUnionParam](spec), update, vaultOutput(cmd)) + }, + } + addVaultSpecFlags(cmd) + cmd.Flags().Lookup("provider").Usage = "provider: link or kernel (required)" + addVaultJSONOutputFlag(cmd) + return cmd +} + func addVaultSpecFlags(cmd *cobra.Command) { cmd.Flags().String("provider", "", "provider: link, agentcard, or kernel (required)") cmd.Flags().String("spec", "", "raw json specification object (required); see types and examples above") diff --git a/cmd/vaults_credentials.go b/cmd/vaults_credentials.go index 7341b1a8..287a4548 100644 --- a/cmd/vaults_credentials.go +++ b/cmd/vaults_credentials.go @@ -10,7 +10,6 @@ import ( "strings" kernel "github.com/kernel/kernel-go-sdk" - "github.com/kernel/kernel-go-sdk/option" "github.com/spf13/cobra" ) @@ -58,11 +57,14 @@ const vaultOnePasswordCredentialHelp = `1Password flow: 3. invoke 1pw_create_access_request once; it needs no browser. present the returned onepassword:// approval link and instructions to the account owner unchanged. 4. invoke 1pw_access_request_status (timeout_seconds 0-120) until the credential is - ready, declined, or failed. ready means approved, not signed in. + ready, declined, or failed. ready means approved, not signed in. for credentials made + with a customer-supplied access token, state.access_request.resolved returns the + non-secret granted references. 5. create a browser with the vault attached, open the login page, and invoke 1pw_fill - with browser_id and the exact page_url. when - several approved entries share that page's origin, pass entry_id from the - state.access_request entries in items get -o json. fill_submitted means the form + with browser_id and the exact page_url. when several open pages have that exact url, + pass target_id (for example tab.target_id from playwright.execute). when several + approved entries share that page's origin, pass entry_id from the state.access_request + entries in items get -o json. fill_submitted means the form was submitted, not that sign-in succeeded. invoke only advertised operations. 1pw_fill can submit the form, so never retry automatically. check the page first before retrying. never automatically retry an @@ -80,8 +82,18 @@ credentials connect again with the same key for a new link.` const vaultOnePasswordStoredTokenHelp = `stored-token 1Password credentials (developer integrations only, separate from the flow above): a developer who already holds a 1Password broker access token and its matching integration key may create the credential with access_token, integration_key, -and requests instead of account. supply either account or both secrets, never both. put them only in a protected --spec-file -or stdin; they are write-only and never displayed. never ask an end user for them. +and requests instead of account. supply either account or both secrets, never both. put +them only in a protected --spec-file or stdin; they are write-only and never displayed. +never ask an end user for them. +for a new approval, omit access_request_id, resolved, and request entry ids, then use +the access-request operations above. if the developer already created an access request +with the same token and integration key and the user approved it, supply access_request_id, 1-5 resolved +entries from the 1Password SDK or extension, and an id on every requests entry. +access_request_id and every entry id must be non-empty and at most 128 characters. +copy each resolved reference unchanged and include its entryId to identify the request +entry it satisfies. entryId may be omitted only when requests has one entry. +the item starts ready and KERNEL does not create another access request. +KERNEL does not verify these references; 1Password checks them during 1pw_fill. 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 @@ -99,7 +111,8 @@ const vaultCredentialHelp = `create credentials for a website. KERNEL-hosted flow: do not use credential items to store, collect, or fill credit card data, including card numbers (pans), security codes (cvv/cvc), or expiration dates. use wallet and -card item types for credit cards and payment checkout instead. +card item types for credit cards and payment checkout instead; where enabled, use a +payment_request item for one Link or KERNEL purchase. first create a vault for the end user and attach it with browsers create --vault. use a protected json file or stdin, never secret values in shell arguments. @@ -191,7 +204,11 @@ 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)") + specFileUsage := "credential spec json file (use '-' for stdin; maximum 128 KiB)" + if !update { + specFileUsage = "credential spec json file (supports pre-approved 1Password access_request_id/resolved; use '-' for stdin; maximum 128 KiB)" + } + cmd.Flags().String("spec-file", "", specFileUsage) _ = cmd.MarkFlagRequired("spec-file") cmd.Flags().Bool("open", false, "open the returned https collection url") addVaultJSONOutputFlag(cmd) @@ -267,13 +284,13 @@ func (c VaultsCmd) saveCredential(ctx context.Context, vault, key string, data [ if expectedID != "" { request.ExpectedItemID = kernel.String(expectedID) } - item, err = c.vaults.Items.Update(ctx, key, kernel.VaultItemUpdateParams{IDOrName: vault, OfCredentialVaultItemUpdateRequest: &request}, option.WithMaxRetries(0)) + item, err = c.vaults.Items.Update(ctx, key, kernel.VaultItemUpdateParams{IDOrName: vault, OfCredentialVaultItemUpdateRequest: &request}, vaultWriteRequestOptions()...) } else { spec, specErr := credentialSpecInput(data) if specErr != nil { return specErr } - item, err = c.vaults.Items.Upsert(ctx, key, kernel.VaultItemUpsertParams{IDOrName: vault, OfCredential: &kernel.CredentialVaultItemRequestParam{Type: "credential", Spec: spec}}, option.WithMaxRetries(0)) + item, err = c.vaults.Items.Upsert(ctx, key, kernel.VaultItemUpsertParams{IDOrName: vault, OfCredential: &kernel.CredentialVaultItemRequestParam{Type: "credential", Spec: spec}}, vaultWriteRequestOptions()...) } if err != nil { return vaultCredentialError(err) @@ -347,7 +364,7 @@ func (c VaultsCmd) connectCredentialAccount(ctx context.Context, vault, key, out }, }, } - item, err := c.vaults.Items.Upsert(ctx, key, kernel.VaultItemUpsertParams{IDOrName: vault, OfCredentialAccount: &request}, option.WithMaxRetries(0)) + item, err := c.vaults.Items.Upsert(ctx, key, kernel.VaultItemUpsertParams{IDOrName: vault, OfCredentialAccount: &request}, vaultWriteRequestOptions()...) if err != nil { return onePasswordConnectError(err) } diff --git a/cmd/vaults_fill.go b/cmd/vaults_fill.go index 14b0b73a..9d44f0c2 100644 --- a/cmd/vaults_fill.go +++ b/cmd/vaults_fill.go @@ -8,7 +8,6 @@ import ( "strconv" kernel "github.com/kernel/kernel-go-sdk" - "github.com/kernel/kernel-go-sdk/option" "github.com/pterm/pterm" ) @@ -114,7 +113,7 @@ func (c VaultsCmd) fill(ctx context.Context, vault, key string, params *vaultFil } request.Fields = append(request.Fields, binding) } - response, err := c.vaults.Items.PerformOperation(ctx, key, kernel.VaultItemPerformOperationParams{IDOrName: vault, OfFill: &request}, option.WithMaxRetries(0)) + response, err := c.vaults.Items.PerformOperation(ctx, key, kernel.VaultItemPerformOperationParams{IDOrName: vault, OfFill: &request}, vaultWriteRequestOptions()...) if err != nil { messages := vaultFillErrorMessages if provider == "kernel" { @@ -224,7 +223,7 @@ const onePasswordFillNotSubmitted = "nothing was submitted by this request; insp // 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. 1pw_fill can submit the form, so never retry automatically. check the page first before retrying.", + 409: "nothing was submitted by this request; if several open pages have page_url, pass target_id, and if several approved entries match its origin, pass entry_id from items get -o json. 1pw_fill can submit the form, so never retry automatically. check the page first before retrying.", } var onePasswordFillResultFields = vaultFieldsOf("type status error_code") @@ -232,7 +231,7 @@ var onePasswordFillResultFields = vaultFieldsOf("type status error_code") func (c VaultsCmd) onePasswordFill(ctx context.Context, vault, key string, request *kernel.VaultItemPerformOperationParams, output string) error { params := *request params.IDOrName = vault - response, err := c.vaults.Items.PerformOperation(ctx, key, params, option.WithMaxRetries(0)) + response, err := c.vaults.Items.PerformOperation(ctx, key, params, vaultWriteRequestOptions()...) if err != nil { var apiErr *kernel.Error if errors.As(err, &apiErr) && apiErr.StatusCode == 503 { diff --git a/cmd/vaults_help.go b/cmd/vaults_help.go index 538e616e..72951b70 100644 --- a/cmd/vaults_help.go +++ b/cmd/vaults_help.go @@ -15,10 +15,13 @@ or normalization. never include card data, OAuth tokens, or provider secrets in const vaultRecoveryHelp = "only if restart_verification is advertised in available_operations, invoke it with explicit user approval each time to get a fresh spend_approval link. restart grants no spend consent: the cardholder must verify and explicitly approve spending again on the new link. if the result is unclear, re-get and share the new action if present; invoke once more only if restart_verification is still advertised and the user approves again. otherwise " + kernelSupportGuidance const vaultWalletSpecHelp = ` -for --provider kernel, use --spec '{}'. share the returned card_enrollment url -with the cardholder; never enter card details in the cli. use wallets get --wait 60 -to observe connection, then wallets payment-methods to inspect token eligibility. -a connected wallet may still be ineligible while network token enrollment runs. +for --provider kernel, use --spec '{}'. if creation returns card_enrollment, +share it with the cardholder; never enter card details in the cli. where the wallet +advertises add_payment_method, creation instead returns an empty connected wallet and +each card is enrolled through that operation. use wallets get --wait 60 to observe +connection, then wallets payment-methods to inspect card status and token eligibility. +a connected wallet may have zero cards or cards that are still ineligible while network +token enrollment runs. ` + kernelWalletHelp + `. ` + kernelEligibilityHelp + `. kernel wallets accept neither provider configuration flags nor --tokens-file. for Link and AgentCard, omit config selection to preserve managed defaults. @@ -54,12 +57,13 @@ type AgentCardWalletSpec = { user_id?: string; // usr_...; enrolled in this organization under the same config }; -// one card stored with KERNEL-managed credentials. creation returns a card_enrollment -// action: the cardholder enters the card on a KERNEL-hosted page, and the wallet -// connects once the card is stored. KERNEL then enrolls it for an agentic network -// token when the issuer supports it; until then payment-methods reports -// capabilities.single_use_card.eligible=false and authorize returns 400. -// the card number never reaches KERNEL or the cli. no provider config or tokens. +// cards stored with KERNEL-managed credentials. each is a wallet payment method. +// where add_payment_method is advertised, creation returns an empty connected wallet; +// invoke add_payment_method once for each card enrollment. otherwise creation returns +// the default card's card_enrollment action and connects after that card is stored. +// KERNEL enrolls stored cards for agentic network tokens when supported; until then +// payment-methods can report capabilities.single_use_card.eligible=false. +// card numbers never reach KERNEL or the cli. no provider config or tokens. type KernelWalletSpec = { provider: "kernel"; }; @@ -107,6 +111,7 @@ type AgentCardCardSpec = { type KernelCardSpec = { provider: "kernel"; wallet: string; // KERNEL wallet item key + payment_method_id?: string; // from wallets payment-methods; omit only for the legacy default card; a wallet with no default card requires it amount: number; // integer minor units; 1..1000000 currency: string; // iso 4217 three letters merchant_name: string; // 1..255 characters @@ -163,4 +168,66 @@ prepared checkout uses preparation merchant_origin instead. permitted domains are not configurable in the spec. Link card fill is limited to the origin of the checkout the card was authorized at; AgentCard domains are provider-assigned. +for KERNEL purchases, ` + kernelFundingStatusHelp + `. +` + +const vaultPaymentRequestSpecHelp = ` +payment_request items are one Link or KERNEL purchase parented by a wallet. they have +the same authorize, fill, confirmation, recovery, and deletion lifecycle as the +corresponding card purchase. availability is organization-controlled. AgentCard reusable +cards remain card items. create and update require type payment_request on the wire; the +cli supplies it. updates are subject to the same authorization-state restrictions as cards. + +type LinkPaymentRequestSpec = { + provider: "link"; + wallet: string; // connected Link wallet item key + payment_method_id: string; // from wallets payment-methods + amount: number; // integer minor units; 1..50000; with recurring, per-period limit + currency: string; // three letters + merchant_name: string; // 1..255 characters + merchant_url: string; // uri; virtual-card fill is limited to this origin + context: string; // at least 100 characters + line_items?: LinkLineItem[]; + totals?: LinkTotal[]; + metadata?: Record; + recurring?: LinkRecurring; +}; + +type KernelPaymentRequestSpec = { + provider: "kernel"; + wallet: string; // KERNEL wallet item key + payment_method_id: string; // required; active eligible method from wallets payment-methods + amount: number; // integer minor units; 1..1000000 + 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 + merchant_country?: string; // iso 3166-1 alpha-2; required for Visa cards + merchant_category?: string; // actual category; omit rather than invent + merchant_category_code?: string; // actual four-digit MCC, not 0000; omit rather than invent +}; + +type LinkRecurring = { + interval: "day" | "week" | "month" | "year"; + interval_count?: number; // integer >= 1 +}; + +type LinkLineItem = { + name: string; + quantity?: number; + unit_amount?: number; + description?: string; + sku?: string; + url?: string; + image_url?: string; + product_url?: string; + totals?: LinkTotal[]; +}; + +type LinkTotal = { type: string; display_text: string; amount: number }; + +KERNEL payment requests must select payment_method_id and remain bound to it. removing +that method makes the purchase unusable rather than moving it to another card. for KERNEL +purchases, ` + kernelFundingStatusHelp + `. Link payment requests follow the Link checkout +flow below. never retry an uncertain payment by creating another payment_request; +reconcile the original item and merchant outcome. ` diff --git a/cmd/vaults_kernel_errors.go b/cmd/vaults_kernel_errors.go index fe9d12d2..2c93c90e 100644 --- a/cmd/vaults_kernel_errors.go +++ b/cmd/vaults_kernel_errors.go @@ -10,11 +10,32 @@ import ( "github.com/kernel/cli/pkg/util" kernel "github.com/kernel/kernel-go-sdk" + "github.com/kernel/kernel-go-sdk/option" ) var kernelVaultErrorURL = regexp.MustCompile(`(?i)https?://\S+`) var kernelVaultErrorCode = regexp.MustCompile(`^[a-zA-Z][a-zA-Z0-9_-]{0,63}$`) +// The SDK adds an Idempotency-Key to every non-GET request. Go's transport treats +// that header plus GetBody as permission to replay a request after some connection +// failures, independently of the SDK retry setting. Vault writes can have side +// effects and must never be replayed implicitly, so remove the transport's replay +// body factory. Keep the key on requests with bodies for server-side classification; +// bodyless writes must drop it because the header alone makes them replayable. +func vaultWriteRequestOptions() []option.RequestOption { + return []option.RequestOption{ + option.WithMaxRetries(0), + option.WithMiddleware(func(req *http.Request, next option.MiddlewareNext) (*http.Response, error) { + req.GetBody = nil + if req.Body == nil || req.Body == http.NoBody { + req.Header.Del("Idempotency-Key") + req.Header.Del("X-Idempotency-Key") + } + return next(req) + }), + } +} + type kernelVaultTransportError struct { message string cause error @@ -57,6 +78,10 @@ func kernelVaultRetryGuidance(operation string) string { switch operation { case "create": return "retry create once with the identical key and identical spec; it retrieves the existing item if creation succeeded. do not use a new key to recover a lost create response" + case "add_payment_method": + return "re-get the wallet with payment_methods expanded before another call; each call creates a separate enrollment link, so never repeat automatically" + case "remove_payment_method": + return "re-get the wallet with payment_methods expanded; retry removal only when the same method remains removal_pending and remove_payment_method is freshly advertised. reconcile any unresolved purchase first" case "authorize", "confirm_transaction", "restart_verification": guidance := "re-get the item and inspect state/events; invoke " + operation + " once more only if freshly advertised in available_operations, never in an automatic loop. otherwise follow the current state. never replay merchant checkout" if operation == "restart_verification" { @@ -108,14 +133,16 @@ func retryAfterWait(apiErr *kernel.Error) string { return "wait for the rate limit to clear" } -const kernelSupportGuidance = "do not retry payment, delete, or replace the item. contact support@kernel.sh privately with project, vault, item key, status_reason and relevant event timestamps (never card data, api keys or capability urls) to reconcile the original operation and establish whether a new purchase is safe; do not assume a new card is safe or promise a resulting state" +const kernelSupportGuidance = "do not retry payment, delete, or replace the item. contact support@kernel.sh privately with project, vault, item key, status_reason and relevant event timestamps (never card data, api keys or capability urls) to reconcile the original operation and establish whether a new purchase is safe; do not assume a replacement purchase is safe or promise a resulting state" const kernelTransactionHelp = "report one observed merchant outcome only when confirm_transaction is advertised. PENDING means the merchant explicitly confirms an accepted transaction is pending; ERROR means an explicit merchant transaction error, not transport loss or an ambiguous processing page. these are final reports, not polling states. unknown means no reliable merchant outcome: ask the user to check order confirmation, merchant account or statement, then report the confirmed outcome even after card expiry. never infer success from fill. if reporting fails or the response is lost, re-get and send the identical observed report once more only if confirm_transaction is freshly advertised; otherwise observe state/events (recorded, in flight or recovery). never repeat merchant checkout" const kernelEligibilityHelp = "eligible=false means do not authorize. for network_token_pending, re-check later at most once in this attempt; network_token_unsupported or network_token_failed needs a different card. if still ineligible or unknown, ask the cardholder before enrolling a different card under a new wallet key; do not poll indefinitely or replay an uncertain purchase" -const kernelWalletHelp = "share card_enrollment only with the intended cardholder privately, never open it for them. reconnect_required: share the new card_enrollment action. degraded: with user confirmation, delete the wallet only if the api permits it before creating a new wallet under a new key for one enrollment; reconcile unresolved child cards first. connected does not imply token eligibility; inspect wallets payment-methods" +const kernelFundingStatusHelp = "funding_status is separate from purchase status. unavailable means the selected payment method is being removed or needs recovery, so authorize, fill, and confirmation are refused until it recovers. removed means the method is gone and this purchase cannot be paid; reconcile its prior outcome before creating a new purchase with another method. neither value rewrites what already happened to the purchase" + +const kernelWalletHelp = "share card_enrollment only with the intended cardholder privately, never open it for them. where add_payment_method is advertised, use it to enroll each card; remove_payment_method removes one method without disconnecting the wallet. reconnect_required marks a legacy wallet awaiting an operator upgrade; reads preserve its state, and payments and deletion are refused until the upgrade. degraded: with user confirmation, delete the wallet only if the api permits it before creating a new wallet under a new key; reconcile unresolved child purchases first. connected may have zero cards and does not imply token eligibility; inspect wallets payment-methods" -const kernelReplacementHelp = "after user confirmation, delete only if the api permits it and create one new card under a new key for a new purchase; reconcile unresolved outcomes first" +const kernelReplacementHelp = "after user confirmation, delete only if the api permits it and create one new purchase item under a new key; reconcile unresolved outcomes first" const kernelTerminalHelp = "merchant decline: report the observed DECLINED outcome when confirm_transaction is advertised. expired approval link or code before checkout: do not submit. if checkout was submitted, reconcile and report the observed outcome first (reporting may remain available after expiry). if never submitted, or after a confirmed decline, " + kernelReplacementHelp + "; ask for a different card or merchant after decline. unknown outcome: ask the user to check the merchant order page, email or statement and report only after they confirm; never guess or repeat checkout" diff --git a/cmd/vaults_kernel_payment_test.go b/cmd/vaults_kernel_payment_test.go index 31b8f441..38806e72 100644 --- a/cmd/vaults_kernel_payment_test.go +++ b/cmd/vaults_kernel_payment_test.go @@ -134,6 +134,27 @@ func TestKernelCardCreateAuthorizeAndApproval(t *testing.T) { assert.Equal(t, 3, calls) } +func TestKernelCardSelectsPaymentMethod(t *testing.T) { + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + var body struct { + Spec map[string]json.RawMessage `json:"spec"` + } + require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + assert.JSONEq(t, `"card-2"`, string(body.Spec["payment_method_id"])) + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, strings.Replace(kernelCardFixture, `"merchant_country":"US"`, `"merchant_country":"US","payment_method_id":"card-2"`, 1)) + }) + spec := `{"wallet":"cardholder","payment_method_id":"card-2","amount":1200,"currency":"usd","merchant_name":"Example Shop","merchant_url":"https://shop.example/checkout","merchant_country":"US"}` + out, _, err := executeVaultCommand(t, client, "vaults", "cards", "create", "checkout", "order-1", "--provider", "kernel", "--spec", spec, "-o", "json") + require.NoError(t, err) + assert.Contains(t, out, `"payment_method_id": "card-2"`) + + cmd, _, err := newVaultsCommand().Find([]string{"cards", "create"}) + require.NoError(t, err) + assert.Contains(t, cmd.Long, "payment_method_id?: string") + assert.Contains(t, cmd.Long, "wallet with no default card") +} + func TestKernelReadyCardUsesAdvertisedFill(t *testing.T) { ready := strings.Replace(kernelCardFixture, `"status":"requested"`, `"status":"ready"`, 1) ready = strings.Replace(ready, `"type":"authorize","description":"Authorize this purchase"`, `"type":"fill","description":"Fill checkout fields"`, 1) diff --git a/cmd/vaults_kernel_recovery_test.go b/cmd/vaults_kernel_recovery_test.go index 12448afe..de7fe9da 100644 --- a/cmd/vaults_kernel_recovery_test.go +++ b/cmd/vaults_kernel_recovery_test.go @@ -218,16 +218,19 @@ func TestKernelRecoveryJSONAndStateGuidance(t *testing.T) { _, human, err := executeVaultCommand(t, client, "vaults", "items", "get", "checkout", "order-1") require.NoError(t, err) switch status { - case "degraded", "reconnect_required": - assert.Contains(t, human, "delete the wallet only if the api permits it") - assert.Contains(t, human, "share the new card_enrollment") + case "degraded": + assert.Contains(t, human, "degraded: with user confirmation, delete the wallet only if the api permits it") + case "reconnect_required": + assert.Contains(t, human, "legacy wallet awaiting an operator upgrade") + assert.Contains(t, human, "payments and deletion are refused until the upgrade") + assert.NotContains(t, human, "share the new card_enrollment") case "requested": assert.Contains(t, human, "Visa may require") assert.NotContains(t, human, "merchant decline") case "recovery_required": assert.Contains(t, human, "support@kernel.sh") assert.Contains(t, human, "project, vault, item key") - assert.Contains(t, human, "do not assume a new card is safe") + assert.Contains(t, human, "do not assume a replacement purchase is safe") out, _, err := executeVaultCommand(t, client, "vaults", "items", "get", "checkout", "order-1", "-o", "json") require.NoError(t, err) assert.NotContains(t, out, `"authorize"`) diff --git a/cmd/vaults_link.go b/cmd/vaults_link.go index 45fcc19e..4ef16415 100644 --- a/cmd/vaults_link.go +++ b/cmd/vaults_link.go @@ -14,12 +14,13 @@ import ( // linkCardFlowHelp follows the API's Link wallet and card operation descriptions. // Every command that documents Link checkout includes it, so the flow reads the same everywhere. -const linkCardFlowHelp = `Link card checkout (each item's available_operations descriptions are authoritative): +const linkCardFlowHelp = `Link card checkout and payment_request checkout (each item's available_operations descriptions are authoritative): 1. reach the final checkout page in a browser created with --vault. note the amount, currency, merchant name and url, and a payment_method_id from wallets payment-methods. -2. create the card with cards create --provider link. this does not contact Link; the - card is requested and advertises authorize. to change the amount or merchant before - authorizing, use cards update. +2. create the card with cards create --provider link, or where enabled create a + payment_request with payment-requests create --provider link. this does not contact + Link; the item is requested and advertises authorize. to change it before authorizing, + use the matching cards update or payment-requests update command. 3. once the user has approved this purchase, authorize with items invoke authorize --browser-id --page-url . KERNEL inspects the checkout, chooses how the credential is delivered (a Link Pay @@ -38,8 +39,9 @@ destination_denied, timeout, or browser_error, no payment request was made: make checkout is open in exactly one tab at page_url, then authorize again. if checkout inspection keeps failing, authorize without --browser-id and --page-url to issue a virtual card that fills any page on the merchant_url origin. once a card is authorized at a checkout, cards update -returns 409: delete the card and create a new one. if authorization is declined or expires, -read status_reason, then delete the card and create a new one. if the result of submitting +returns 409: delete the card and create a new one. the same rule applies to payment-requests +update: delete the payment_request and create a new one. if authorization is declined or +expires, read status_reason, then delete the item and create a new one. if the result of submitting payment is unclear, check the merchant's order confirmation or order history before paying again. for a recurring card, amount is the limit for each declared period. the merchant schedules later charges and the same card remains ready until it expires or is deleted; do not create a diff --git a/cmd/vaults_no_replay_test.go b/cmd/vaults_no_replay_test.go new file mode 100644 index 00000000..a34bd3d3 --- /dev/null +++ b/cmd/vaults_no_replay_test.go @@ -0,0 +1,55 @@ +package cmd + +import ( + "fmt" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestVaultWritesAreNeverTransportReplayed(t *testing.T) { + t.Setenv("KERNEL_PROJECT", "") + spec := `{"wallet":"cardholder","payment_method_id":"card-1","amount":1200,"currency":"usd","merchant_name":"Example Shop","merchant_url":"https://shop.example/checkout"}` + + t.Run("body", func(t *testing.T) { + writes := 0 + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.Method == http.MethodGet { + _, _ = fmt.Fprint(w, kernelPaymentRequestFixture) + return + } + writes++ + conn, _, err := w.(http.Hijacker).Hijack() + require.NoError(t, err) + _ = conn.Close() + }) + _, _, err := executeVaultCommand(t, client, "vaults", "items", "get", "user", "checkout") + require.NoError(t, err) // Prime a reusable connection so net/http's replay path is exercised. + _, _, err = executeVaultCommand(t, client, "vaults", "payment-requests", "update", "user", "checkout", "--provider", "kernel", "--spec", spec) + require.Error(t, err) + assert.Equal(t, 1, writes) + }) + + t.Run("bodyless", func(t *testing.T) { + writes := 0 + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.Method == http.MethodGet { + _, _ = fmt.Fprint(w, kernelPaymentRequestFixture) + return + } + writes++ + conn, _, err := w.(http.Hijacker).Hijack() + require.NoError(t, err) + _ = conn.Close() + }) + _, _, err := executeVaultCommand(t, client, "vaults", "items", "get", "user", "checkout") + require.NoError(t, err) + _, _, err = executeVaultCommand(t, client, "vaults", "items", "delete", "user", "checkout", "--yes") + require.Error(t, err) + assert.Equal(t, 1, writes) + }) +} diff --git a/cmd/vaults_onepassword_resolved_test.go b/cmd/vaults_onepassword_resolved_test.go new file mode 100644 index 00000000..ba54ac82 --- /dev/null +++ b/cmd/vaults_onepassword_resolved_test.go @@ -0,0 +1,38 @@ +package cmd + +import ( + "io" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestVaultOnePasswordResolvedReferencesAreDisplayed(t *testing.T) { + fixture := `{ + "id":"credential-2","key":"github","type":"credential","version":1, + "spec":{"provider":"1password","requests":{"version":2,"entries":[{"id":"entry-1","type":"login","parameters":{"website":"https://github.com"}}]}}, + "state":{"provider":"1password","status":"ready","access_request":{"id":"req-1","state":"approved","has_autofill_token":false,"granted_count":1,"resolved":[{"reference":"op://Private/GitHub/login","entryId":"entry-1","secret":"never-print"}]}}, + "available_operations":[{"type":"1pw_fill","description":"Fill the approved login"}], + "available_expansions":[] + }` + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, fixture) + }) + out, _, err := executeVaultCommand(t, client, "vaults", "items", "get", "user", "github", "-o", "json") + require.NoError(t, err) + assert.Contains(t, out, `"id": "entry-1"`) + assert.Contains(t, out, `"resolved"`) + assert.Contains(t, out, `"reference": "op://Private/GitHub/login"`) + assert.Contains(t, out, `"entryId": "entry-1"`) + assert.NotContains(t, out, "never-print") + + _, text, err := executeVaultCommand(t, client, "vaults", "items", "get", "user", "github") + require.NoError(t, err) + assert.Contains(t, text, "resolved 1Password reference") + assert.Contains(t, text, "op://Private/GitHub/login") + assert.Contains(t, text, "entry entry-1") + assert.NotContains(t, text, "never-print") +} diff --git a/cmd/vaults_onepassword_test.go b/cmd/vaults_onepassword_test.go index cd07bf78..5423f9e9 100644 --- a/cmd/vaults_onepassword_test.go +++ b/cmd/vaults_onepassword_test.go @@ -50,10 +50,12 @@ func TestCredentialConnectOnePassword(t *testing.T) { func TestCredentialCreateOnePassword(t *testing.T) { t.Setenv("KERNEL_PROJECT", "") requests := `"requests":{"version":2,"entries":[{"type":"login","parameters":{"website":"https://github.com"},"reason":"Work account"},{"type":"login","parameters":{"website":"https://github.com"},"reason":"Personal account"}]}` + preResolved := `{"provider":"1password","access_token":"token-secret","integration_key":"key-secret","access_request_id":"request-approved","requests":{"version":2,"entries":[{"id":"entry-work","type":"login","parameters":{"website":"https://github.com"}},{"id":"entry-personal","type":"login","parameters":{"website":"https://github.com"}}]},"resolved":[{"reference":"op://Private/GitHub Work/login","entryId":"entry-work"},{"reference":"op://Private/GitHub Personal/login","entryId":"entry-personal"}]}` entry := `{"type":"login","parameters":{"website":"https://github.com"}}` for _, spec := range []string{ `{"provider":"1password","account":"onepassword",` + requests + `}`, `{"provider":"1password","access_token":"token-secret","integration_key":"key-secret",` + requests + `}`, + preResolved, } { calls := 0 client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { @@ -179,6 +181,7 @@ func TestOnePasswordOperationValidation(t *testing.T) { {"1pw_fill", `{"browser_id":"b"}`, "page_url"}, {"1pw_fill", `{"browser_id":"b","page_url":"https://github.com/login","timeout_ms":0}`, "timeout_ms"}, {"1pw_fill", `{"browser_id":"b","page_url":"https://github.com/login","entry_id":""}`, "entry_id"}, + {"1pw_fill", `{"browser_id":"b","page_url":"https://github.com/login","target_id":""}`, "target_id"}, {"1pw_update_access_token", `{"access_token":"token-secret"}`, "only through --spec-file"}, {"1pw_recover", `{}`, "takes no parameters"}, } { @@ -294,6 +297,15 @@ func TestCredentialHelpPresentsBothPaths(t *testing.T) { } assert.Contains(t, credentials.Long, "stored-token 1Password credentials (developer integrations only") assert.Contains(t, credentials.Long, "never ask an end user for them") + assert.Contains(t, credentials.Long, "omit access_request_id, resolved, and request entry ids") + assert.Contains(t, credentials.Long, "supply access_request_id, 1-5 resolved") + assert.Contains(t, credentials.Long, "an id on every requests entry") + assert.Contains(t, credentials.Long, "access_request_id and every entry id must be non-empty and at most 128 characters") + assert.Contains(t, credentials.Long, "entryId may be omitted only when requests has one entry") + assert.Contains(t, credentials.Long, "item starts ready and KERNEL does not create another access request") + assert.Contains(t, credentials.Long, "1Password checks them during 1pw_fill") + require.NotNil(t, credentials.Flag("spec-file")) + assert.Contains(t, credentials.Flag("spec-file").Usage, "access_request_id/resolved") assert.NotContains(t, connect.Long, "stored-token") assert.Contains(t, connect.Long, "credential_account") assert.Contains(t, credentials.Example, `"provider":"1password","account":"onepassword"`) @@ -302,8 +314,12 @@ func TestCredentialHelpPresentsBothPaths(t *testing.T) { } assert.Contains(t, invoke.Long, "--spec-file, never --params") assert.Contains(t, invoke.Example, `"entry_id":""`) + assert.Contains(t, invoke.Example, `"target_id":""`) + assert.Contains(t, invoke.Long, "target_id when several open pages") + assert.Contains(t, invoke.Long, "optional access_token_expires_at (RFC3339)") + assert.Contains(t, invoke.Long, "expiry clears the previously supplied expiry") for _, help := range []string{invoke.Long, invoke.Example, credentials.Long, credentials.Example, connect.Long} { - for _, removed := range []string{"1pw_request_access", "1pw_poll_access", "1pw_reconcile", "account_id", "Family", "customer_managed", "access_token_expires_at", "RFC 3339"} { + for _, removed := range []string{"1pw_request_access", "1pw_poll_access", "1pw_reconcile", "account_id", "Family", "customer_managed", "RFC 3339"} { assert.NotContains(t, help, removed) } } @@ -345,8 +361,8 @@ func TestOnePasswordFillEntryAndTokenUpdateRequests(t *testing.T) { for _, tc := range []struct { operation, params, body, response string }{ - {"1pw_fill", `{"browser_id":"browser-1","page_url":"https://github.com/login","entry_id":"entry-1"}`, `{"type":"1pw_fill","browser_id":"browser-1","page_url":"https://github.com/login","entry_id":"entry-1"}`, `{"type":"1pw_fill","status":"fill_submitted"}`}, - {"1pw_update_access_token", `{"access_token":"token-secret"}`, `{"type":"1pw_update_access_token","access_token":"token-secret"}`, onePasswordCredentialWithOperation("1pw_update_access_token")}, + {"1pw_fill", `{"browser_id":"browser-1","page_url":"https://github.com/login","entry_id":"entry-1","target_id":"target-1"}`, `{"type":"1pw_fill","browser_id":"browser-1","page_url":"https://github.com/login","entry_id":"entry-1","target_id":"target-1"}`, `{"type":"1pw_fill","status":"fill_submitted"}`}, + {"1pw_update_access_token", `{"access_token":"token-secret","access_token_expires_at":"2026-10-01T00:00:00Z"}`, `{"type":"1pw_update_access_token","access_token":"token-secret","access_token_expires_at":"2026-10-01T00:00:00Z"}`, onePasswordCredentialWithOperation("1pw_update_access_token")}, } { t.Run(tc.operation, func(t *testing.T) { posts := 0 @@ -371,7 +387,7 @@ func TestOnePasswordFillEntryAndTokenUpdateRequests(t *testing.T) { calls := 0 client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { calls++ }) - for _, params := range []string{`{"access_token":""}`, `{"access_token":"token-secret","access_token_expires_at":"2026-10-01T00:00:00Z"}`, `{"access_token":"token-secret","integration_key":"key-secret"}`} { + for _, params := range []string{`{"access_token":""}`, `{"access_token":"token-secret","access_token_expires_at":""}`, `{"access_token":"token-secret","access_token_expires_at":"not-a-date"}`, `{"access_token":"token-secret","access_token_expires_at":null}`, `{"access_token":"token-secret","integration_key":"key-secret"}`} { _, _, err := executeVaultCommand(t, client, "vaults", "items", "invoke", "user", "github", "1pw_update_access_token", "--spec-file", credentialSpecFile(t, params)) require.Error(t, err, params) assert.NotContains(t, err.Error(), "-secret") diff --git a/cmd/vaults_operation_params.go b/cmd/vaults_operation_params.go index 96f4e2c2..9e4e832d 100644 --- a/cmd/vaults_operation_params.go +++ b/cmd/vaults_operation_params.go @@ -7,6 +7,7 @@ import ( "net/url" "slices" "strings" + "time" kernel "github.com/kernel/kernel-go-sdk" ) @@ -17,9 +18,11 @@ type vaultOperationParams struct { WebMCP *kernel.WebmcpInvokeVaultItemOperationRequestParam Checkout *kernel.VaultCheckoutContextParam ConfirmTransaction *vaultConfirmTransactionParams - // Passkey and OnePassword are complete operation request bodies; Invoke supplies the vault. - Passkey *kernel.VaultItemPerformOperationParams - OnePassword *kernel.VaultItemPerformOperationParams + // Passkey, OnePassword, and PaymentMethod are complete operation request bodies; + // Invoke supplies the vault. + Passkey *kernel.VaultItemPerformOperationParams + OnePassword *kernel.VaultItemPerformOperationParams + PaymentMethod *kernel.VaultItemPerformOperationParams } func isOnePasswordOperation(operation string) bool { @@ -28,7 +31,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 == "webmcp_invoke" || operation == "prepare_checkout" || operation == "confirm_transaction" || (isOnePasswordOperation(operation) && operation != "1pw_recover") + return operation == "fill" || operation == "webmcp_invoke" || operation == "prepare_checkout" || operation == "confirm_transaction" || operation == "remove_payment_method" || (isOnePasswordOperation(operation) && operation != "1pw_recover") } type vaultFillParams struct { @@ -120,6 +123,33 @@ func parseVaultOperationParams(operation, raw string, paramsSet, openSet bool) ( } return &vaultOperationParams{OnePassword: request}, nil } + if operation == "add_payment_method" { + if paramsSet { + return nil, fmt.Errorf("add_payment_method takes no parameters") + } + return &vaultOperationParams{PaymentMethod: &kernel.VaultItemPerformOperationParams{ + OfAddPaymentMethod: &kernel.AddPaymentMethodVaultItemOperationRequestParam{Type: kernel.AddPaymentMethodVaultItemOperationRequestTypeAddPaymentMethod}, + }}, nil + } + if operation == "remove_payment_method" { + if !paramsSet { + return nil, fmt.Errorf("remove_payment_method requires --params or --spec-file with payment_method_id") + } + object, err := vaultParamsObject(raw, "payment_method_id") + if err != nil { + return nil, err + } + var paymentMethodID string + if json.Unmarshal(object["payment_method_id"], &paymentMethodID) != nil || strings.TrimSpace(paymentMethodID) == "" || len(paymentMethodID) > 128 { + return nil, fmt.Errorf("payment_method_id must be a non-empty string of at most 128 bytes") + } + return &vaultOperationParams{PaymentMethod: &kernel.VaultItemPerformOperationParams{ + OfRemovePaymentMethod: &kernel.RemovePaymentMethodVaultItemOperationRequestParam{ + Type: kernel.RemovePaymentMethodVaultItemOperationRequestTypeRemovePaymentMethod, + PaymentMethodID: paymentMethodID, + }, + }}, nil + } if operation == "confirm_transaction" { if !paramsSet { return nil, fmt.Errorf("confirm_transaction requires --params or --spec-file") @@ -152,7 +182,7 @@ func parseVaultOperationParams(operation, raw string, paramsSet, openSet bool) ( } if operation != "fill" { if paramsSet { - return nil, fmt.Errorf("--params is only supported for fill, webmcp_invoke, prepare_checkout, confirm_transaction, and 1Password operations; authorize takes --browser-id and --page-url, passkey operations take --browser-id and optional --field, and restart_verification takes no parameters") + return nil, fmt.Errorf("--params is only supported for fill, webmcp_invoke, prepare_checkout, confirm_transaction, remove_payment_method, and 1Password operations; authorize takes --browser-id and --page-url, passkey operations take --browser-id and optional --field, and add_payment_method and restart_verification take no parameters") } return nil, nil } @@ -225,8 +255,8 @@ func parseOnePasswordOperationParams(operation, raw string) (*kernel.VaultItemPe allowed := map[string]string{ "1pw_create_access_request": "goal reason keywords", "1pw_access_request_status": "timeout_seconds", - "1pw_fill": "browser_id page_url entry_id timeout_ms", - "1pw_update_access_token": "access_token", + "1pw_fill": "browser_id page_url target_id entry_id timeout_ms", + "1pw_update_access_token": "access_token access_token_expires_at", }[operation] if allowed == "" { return nil, fmt.Errorf("unsupported 1Password operation %q", operation) @@ -240,6 +270,17 @@ func parseOnePasswordOperationParams(operation, raw string) (*kernel.VaultItemPe if json.Unmarshal(object["access_token"], &request.AccessToken) != nil || strings.TrimSpace(request.AccessToken) == "" || len(request.AccessToken) > 16384 { return nil, fmt.Errorf("access_token must be a non-empty string of at most 16384 bytes") } + if value, ok := object["access_token_expires_at"]; ok { + var text string + if json.Unmarshal(value, &text) != nil { + return nil, fmt.Errorf("access_token_expires_at must be an RFC3339 date-time") + } + expiresAt, err := time.Parse(time.RFC3339, text) + if err != nil { + return nil, fmt.Errorf("access_token_expires_at must be an RFC3339 date-time") + } + request.AccessTokenExpiresAt = kernel.Opt(expiresAt) + } return &kernel.VaultItemPerformOperationParams{Of1pwUpdateAccessToken: &request}, nil } switch operation { @@ -291,6 +332,13 @@ func parseOnePasswordOperationParams(operation, raw string) (*kernel.VaultItemPe } request.TimeoutMs = kernel.Opt(*timeout) } + if value, ok := object["target_id"]; ok { + var targetID string + if json.Unmarshal(value, &targetID) != nil || strings.TrimSpace(targetID) == "" { + return nil, fmt.Errorf("target_id must be a non-empty CDP target ID for an open page") + } + request.TargetID = kernel.Opt(targetID) + } if value, ok := object["entry_id"]; ok { var entryID string if json.Unmarshal(value, &entryID) != nil || strings.TrimSpace(entryID) == "" { diff --git a/cmd/vaults_output.go b/cmd/vaults_output.go index d05c23fd..c1872022 100644 --- a/cmd/vaults_output.go +++ b/cmd/vaults_output.go @@ -33,6 +33,8 @@ var onePasswordRequestEntryFields = vaultOutputFields{ var onePasswordRequestFields = vaultOutputFields{"version": nil, "goal": nil, "entries": onePasswordRequestEntryFields} var vaultMethodFields = vaultOutputFields{ "id": nil, "provider": nil, "type": nil, "is_default": nil, + "status": nil, "status_reason": nil, "expires_at": nil, + "action": vaultFieldsOf("name url"), "display": vaultFieldsOf("label brand last4"), "capabilities": {"single_use_card": vaultFieldsOf("eligible reasons")}, } @@ -82,12 +84,15 @@ var vaultItemFields = vaultOutputFields{ "access_request": { "id": nil, "state": nil, "goal": nil, "createdAt": nil, "has_autofill_token": nil, "granted_count": nil, "request": onePasswordRequestFields, "entries": onePasswordRequestEntryFields, + "resolved": vaultFieldsOf("reference entryId"), }, - "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"), + "funding_status": nil, + "payment_method_id": nil, + "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"), }, } var vaultEventFields = vaultOutputFields{ @@ -366,7 +371,7 @@ func printVaultOperationHints(item *kernel.VaultItemUnion, vault, key, project s command += " --field ''" } } - if op.Type == "authorize" && item.Type == "card" && item.Spec.Provider == "link" && item.State.Status == "requested" { + if op.Type == "authorize" && (item.Type == "card" || item.Type == "payment_request") && item.Spec.Provider == "link" && item.State.Status == "requested" { command += " --browser-id '' --page-url ''" } pterm.Printf("invoke: %s -- %s %s %s\n", command, vaultShellArgument(vault), vaultShellArgument(key), vaultShellArgument(op.Type)) @@ -421,6 +426,13 @@ func printVaultItem(item *kernel.VaultItemUnion, output string) error { entryRow(entry.ID, entry.Parameters.Website) } } + for _, resolved := range item.State.AccessRequest.Resolved { + label := resolved.Reference + if resolved.EntryID != "" { + label += " (entry " + resolved.EntryID + ")" + } + rows = append(rows, []string{"resolved 1Password reference", label}) + } } } else if item.Spec.Provider == "managed_auth" { rows = append(rows, []string{"managed auth connection (immutable)", item.Spec.ConnectionID}) @@ -444,24 +456,24 @@ func printVaultItem(item *kernel.VaultItemUnion, output string) error { if item.State.StatusReason != "" { rows = append(rows, []string{"status reason", item.State.StatusReason}) } - if item.Type == "card" { + if item.Type == "card" || item.Type == "payment_request" { merchant := item.Spec.MerchantName if item.Spec.Provider == "agentcard" { merchant = item.Spec.Merchant } rows = append(rows, []string{"wallet key", item.Spec.Wallet}, []string{"merchant", merchant}, []string{"amount (minor units)", fmt.Sprintf("%d %s", item.Spec.Amount, item.Spec.Currency)}) - if item.Spec.Provider == "link" { + if item.Spec.PaymentMethodID != "" { rows = append(rows, []string{"payment method id", item.Spec.PaymentMethodID}) - if item.Spec.JSON.Recurring.Valid() { - intervalCount := "1 (default)" - if item.Spec.Recurring.JSON.IntervalCount.Valid() { - intervalCount = fmt.Sprint(item.Spec.Recurring.IntervalCount) - } - rows = append(rows, - []string{"recurring interval", item.Spec.Recurring.Interval}, - []string{"recurring interval count", intervalCount}, - ) + } + if item.Spec.Provider == "link" && item.Spec.JSON.Recurring.Valid() { + intervalCount := "1 (default)" + if item.Spec.Recurring.JSON.IntervalCount.Valid() { + intervalCount = fmt.Sprint(item.Spec.Recurring.IntervalCount) } + rows = append(rows, + []string{"recurring interval", item.Spec.Recurring.Interval}, + []string{"recurring interval count", intervalCount}, + ) } if item.Spec.Provider == "agentcard" && item.Spec.CheckoutOrigin != "" { rows = append(rows, []string{"checkout origin", item.Spec.CheckoutOrigin}) @@ -472,6 +484,12 @@ func printVaultItem(item *kernel.VaultItemUnion, output string) error { if item.Spec.Provider == "kernel" && item.Spec.MerchantCountry != "" { rows = append(rows, []string{"merchant country", item.Spec.MerchantCountry}) } + if item.State.JSON.FundingStatus.Valid() { + rows = append(rows, []string{"funding status", string(item.State.FundingStatus)}) + } + if item.State.JSON.PaymentMethodID.Valid() && item.State.PaymentMethodID != item.Spec.PaymentMethodID { + rows = append(rows, []string{"funding payment method id", item.State.PaymentMethodID}) + } if masks := item.State.Masks; masks.Last4 != "" || masks.TokenLast4 != "" { rows = append(rows, []string{"card last4", util.OrDash(masks.Last4)}) if masks.TokenLast4 != "" { @@ -569,11 +587,11 @@ func printVaultItemGuidance(item *kernel.VaultItemUnion, actions vaultItemAction 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.") } - pterm.Info.Println("do not use credential items for credit card data. use wallet and card item types for credit cards and payment checkout instead.") + pterm.Info.Println("do not use credential items for credit card data. use wallet and card item types for credit cards and payment checkout instead; where enabled, use payment_request for one Link or KERNEL purchase.") pterm.Info.Println("ready means required fields are populated, not that login succeeded. fill only when advertised; fill does not submit the form. passkey fields are never filled: use register_passkey, arm_passkey, and disarm_passkey only when advertised.") return } - if item.Type == "card" { + if item.Type == "card" || item.Type == "payment_request" { if item.Spec.Provider == "kernel" { switch item.State.Status { case "requested", "pending_authorization", "ready": @@ -593,25 +611,35 @@ func printVaultItemGuidance(item *kernel.VaultItemUnion, actions vaultItemAction } pterm.Info.Println("preparations are single-use, including after failure or expiry. preparation consumed means claimed, not payment success. do not retry automatically.") } - card := item.AsCard() - for _, expansion := range card.AvailableExpansions { - pterm.Printf("available expansion: %s — %s\n", expansion.Type, expansion.Description) + if item.Type == "card" { + card := item.AsCard() + for _, expansion := range card.AvailableExpansions { + pterm.Printf("available expansion: %s — %s\n", expansion.Type, expansion.Description) + } + } else { + request := item.AsPaymentRequest() + for _, expansion := range request.AvailableExpansions { + pterm.Printf("available expansion: %s — %s\n", expansion.Type, expansion.Description) + } } if item.State.JSON.Aliases.Valid() { pterm.Info.Println("aliases are non-secret checkout values. use only in a browser created with this vault attached; ready does not mean paid.") } switch item.Spec.Provider { case "kernel": - pterm.Info.Println(kernelTransactionHelp + ". kernel cards have no stored billing fields; billing_* returns field_unavailable. enter billing details on the merchant page.") + pterm.Info.Println(kernelTransactionHelp + ". kernel card and payment_request purchases have no stored billing fields; billing_* returns field_unavailable. enter billing details on the merchant page.") + if item.State.JSON.FundingStatus.Valid() { + pterm.Info.Println(kernelFundingStatusHelp + ".") + } case "link": - pterm.Info.Println("fill never submits payment; submit the checkout yourself. if the result of submitting payment is unclear, check the merchant's order confirmation or order history before paying again. if authorization is declined or expires, delete this card and create a new one.") + pterm.Info.Println("fill never submits payment; submit the checkout yourself. if the result of submitting payment is unclear, check the merchant's order confirmation or order history before paying again. if authorization is declined or expires, delete this purchase item and create a new one.") if item.Spec.JSON.Recurring.Valid() { - pterm.Info.Println("this recurring card remains ready for merchant-scheduled charges until it expires or is deleted; do not create a new card each period. deleting the card, wallet, or vault revokes the Link credential but does not cancel the merchant subscription. if deletion returns 409 provider_revocation_pending, retry deletion.") + pterm.Info.Println("this recurring Link purchase remains ready for merchant-scheduled charges until it expires or is deleted; do not create a new card each period, or a new payment_request for the same recurring purchase. deleting the item, wallet, or vault revokes the Link credential but does not cancel the merchant subscription. if deletion returns 409 provider_revocation_pending, retry deletion.") } default: - pterm.Info.Println("inspect items events for payment outcomes. never retry automatically; if recovery permits abandonment, delete the card only after explicit user confirmation before creating a replacement.") + pterm.Info.Println("inspect items events for payment outcomes. never retry automatically; if recovery permits abandonment, delete the purchase item only after explicit user confirmation before creating a replacement.") } - } else { + } else if item.Type == "wallet" { wallet := item.AsWallet() if item.Description != "" { pterm.Info.Println(item.Description) @@ -637,25 +665,66 @@ func printVaultItemGuidance(item *kernel.VaultItemUnion, actions vaultItemAction } } +var vaultAddPaymentMethodResultFields = vaultOutputFields{"type": nil, "payment_method": vaultMethodFields} +var vaultRemovePaymentMethodResultFields = vaultFieldsOf("type status payment_method_id") + +func printVaultPaymentMethodOperationResult(response *kernel.VaultItemOperationResponseUnion, operation, output string) error { + if response == nil || response.Type != operation { + return fmt.Errorf("unexpected %s response; inspect the wallet before retrying", operation) + } + fields := vaultAddPaymentMethodResultFields + if operation == "remove_payment_method" { + fields = vaultRemovePaymentMethodResultFields + } + raw, err := filterVaultJSON(json.RawMessage(response.RawJSON()), fields) + if err != nil { + return fmt.Errorf("invalid %s response; inspect the wallet before retrying", operation) + } + if output == "json" { + return printVaultJSON(raw) + } + if operation == "add_payment_method" { + result := response.AsAddPaymentMethodVaultItemOperationResult() + pterm.Success.Println("Started payment method enrollment") + printVaultPaymentMethods("kernel", []kernel.VaultPaymentMethod{result.PaymentMethod}) + pterm.Info.Println("share the single-use card_enrollment url only with the intended cardholder; it expires after 30 minutes. every call creates a separate link, so never retry automatically. observe completion with wallets payment-methods; an expired or failed enrollment does not affect other methods.") + return nil + } + result := response.AsRemovePaymentMethodVaultItemOperationResult() + pterm.Success.Printf("Removed payment method: %s\n", result.PaymentMethodID) + pterm.Info.Println("removal is not proof that an unresolved payment did not occur; reconcile the purchase separately.") + return nil +} + func printVaultPaymentMethods(provider string, methods []kernel.VaultPaymentMethod) { if len(methods) == 0 { pterm.Info.Println("no payment methods returned") return } - rows := pterm.TableData{{"payment method id", "provider", "type", "label", "brand", "last4", "default", "single-use eligible", "reasons"}} + rows := pterm.TableData{{"payment method id", "provider", "type", "label", "brand", "last4", "default", "status", "single-use eligible", "reasons", "status reason", "expires at"}} for _, m := range methods { capability := m.Capabilities.SingleUseCard eligible := "unknown" if capability.JSON.Eligible.Valid() { eligible = fmt.Sprint(capability.Eligible) } - rows = append(rows, []string{m.ID, m.Provider, m.Type, m.Display.Label, m.Display.Brand, m.Display.Last4, fmt.Sprint(m.IsDefault), eligible, strings.Join(capability.Reasons, ", ")}) + expiresAt := "" + if m.JSON.ExpiresAt.Valid() && !m.ExpiresAt.IsZero() { + expiresAt = util.FormatLocal(m.ExpiresAt) + } + rows = append(rows, []string{m.ID, m.Provider, m.Type, m.Display.Label, m.Display.Brand, m.Display.Last4, fmt.Sprint(m.IsDefault), string(m.Status), eligible, strings.Join(capability.Reasons, ", "), m.StatusReason, expiresAt}) } PrintTableNoPad(rows, true) + for _, method := range methods { + if method.Action.JSON.URL.Valid() && vaultDisplayURL(method.Action.URL) { + pterm.Printf("payment method %s %s url:\n%s\n", method.ID, method.Action.Name, method.Action.URL) + } + } if provider == "kernel" { - pterm.Info.Println("kernel cards use the wallet key, not a payment method id. " + kernelEligibilityHelp) + pterm.Info.Println("select payment_method_id explicitly for payment_request items and for card items when the wallet has no default card. a legacy default card may be selected by omitting it only from a card spec. " + kernelEligibilityHelp) + pterm.Info.Println("pending_enrollment needs the returned cardholder action. removal_pending means retry remove_payment_method later; recovery_required handling depends on status_reason.") } else { - pterm.Info.Println("select an id explicitly in the card --spec json: Link uses payment_method_id; AgentCard uses card_id (or omit it for cardholder selection). capabilities are advisory; missing means unknown, not ineligible.") + pterm.Info.Println("select an id explicitly in the purchase --spec json: Link uses payment_method_id; AgentCard cards use card_id (or omit it for cardholder selection). capabilities are advisory; missing means unknown, not ineligible.") } } diff --git a/cmd/vaults_payment_method_operations_test.go b/cmd/vaults_payment_method_operations_test.go new file mode 100644 index 00000000..118498f1 --- /dev/null +++ b/cmd/vaults_payment_method_operations_test.go @@ -0,0 +1,154 @@ +package cmd + +import ( + "io" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const kernelWalletPaymentOperationsFixture = `{ + "id":"wallet-id","key":"cardholder","type":"wallet", + "spec":{"provider":"kernel"}, + "state":{"provider":"kernel","status":"connected"}, + "available_operations":[ + {"type":"add_payment_method","description":"Enroll another card"}, + {"type":"remove_payment_method","description":"Remove one enrolled card"} + ], + "available_expansions":[{"type":"payment_methods","description":"List cards"}] +}` + +func TestVaultAddPaymentMethodOperation(t *testing.T) { + t.Setenv("KERNEL_PROJECT", "") + posts := 0 + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.Method == http.MethodGet { + _, _ = io.WriteString(w, kernelWalletPaymentOperationsFixture) + return + } + posts++ + body, err := io.ReadAll(r.Body) + require.NoError(t, err) + assert.JSONEq(t, `{"type":"add_payment_method"}`, string(body)) + _, _ = io.WriteString(w, `{ + "type":"add_payment_method", + "payment_method":{ + "id":"card-2","provider":"kernel","type":"card","is_default":false, + "status":"pending_enrollment","status_reason":"awaiting_cardholder", + "expires_at":"2026-09-01T12:30:00Z", + "display":{},"capabilities":{}, + "action":{"name":"card_enrollment","url":"https://vault.example/enroll?state=opaque","capture_token":"secret-capture"}, + "provider_secret":"secret-provider" + } + }`) + }) + out, text, err := executeVaultCommand(t, client, "vaults", "items", "invoke", "checkout", "cardholder", "add_payment_method", "-o", "json") + require.NoError(t, err) + assert.Equal(t, 1, posts) + for _, want := range []string{`"type": "add_payment_method"`, `"status": "pending_enrollment"`, `"card_enrollment"`, "https://vault.example/enroll?state=opaque"} { + assert.Contains(t, out, want) + } + assert.Empty(t, text) + assert.NotContains(t, out, "secret-capture") + assert.NotContains(t, out, "secret-provider") + + _, text, err = executeVaultCommand(t, client, "vaults", "items", "invoke", "checkout", "cardholder", "add_payment_method") + require.NoError(t, err) + for _, want := range []string{"Started payment method enrollment", "card-2", "pending_enrollment", "card_enrollment url", "single-use", "expires after 30 minutes", "separate link"} { + assert.Contains(t, text, want) + } + assert.NotContains(t, text, "secret-") + assert.Equal(t, 2, posts) +} + +func TestVaultRemovePaymentMethodOperation(t *testing.T) { + t.Setenv("KERNEL_PROJECT", "") + posts := 0 + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.Method == http.MethodGet { + _, _ = io.WriteString(w, kernelWalletPaymentOperationsFixture) + return + } + posts++ + body, err := io.ReadAll(r.Body) + require.NoError(t, err) + assert.JSONEq(t, `{"type":"remove_payment_method","payment_method_id":"card-2"}`, string(body)) + _, _ = io.WriteString(w, `{"type":"remove_payment_method","status":"removed","payment_method_id":"card-2","provider_secret":"secret-provider"}`) + }) + out, _, err := executeVaultCommand(t, client, "vaults", "items", "invoke", "checkout", "cardholder", "remove_payment_method", "--params", `{"payment_method_id":"card-2"}`, "-o", "json") + require.NoError(t, err) + assert.Equal(t, 1, posts) + assert.Contains(t, out, `"status": "removed"`) + assert.Contains(t, out, `"payment_method_id": "card-2"`) + assert.NotContains(t, out, "secret-provider") +} + +func TestVaultPaymentMethodOperationValidation(t *testing.T) { + calls := 0 + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { calls++ }) + for _, tc := range []struct { + operation string + params string + want string + }{ + {"add_payment_method", `{}`, "takes no parameters"}, + {"remove_payment_method", "", "requires --params"}, + {"remove_payment_method", `{}`, "payment_method_id"}, + {"remove_payment_method", `{"payment_method_id":""}`, "payment_method_id"}, + {"remove_payment_method", `{"payment_method_id":"card-1","other":true}`, "only supported"}, + } { + args := []string{"vaults", "items", "invoke", "checkout", "cardholder", tc.operation} + if tc.params != "" { + args = append(args, "--params", tc.params) + } + _, _, err := executeVaultCommand(t, client, args...) + require.ErrorContains(t, err, tc.want) + } + assert.Zero(t, calls) +} + +func TestVaultPaymentMethodsExposeLifecycleWithoutCaptureToken(t *testing.T) { + fixture := strings.TrimSuffix(kernelWalletPaymentOperationsFixture, "}") + `, + "expanded":{"payment_methods":[{ + "id":"card-2","provider":"kernel","type":"card","is_default":false, + "status":"pending_enrollment","status_reason":"awaiting_cardholder", + "expires_at":"2026-09-01T12:30:00Z", + "display":{"brand":"visa","last4":"1234"},"capabilities":{}, + "action":{"name":"card_enrollment","url":"https://vault.example/enroll?state=opaque","capture_token":"secret-capture"} + }]}}` + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "payment_methods", r.URL.Query().Get("expand")) + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, fixture) + }) + out, _, err := executeVaultCommand(t, client, "vaults", "wallets", "payment-methods", "checkout", "cardholder", "-o", "json") + require.NoError(t, err) + for _, want := range []string{`"status": "pending_enrollment"`, `"status_reason": "awaiting_cardholder"`, `"expires_at"`, `"name": "card_enrollment"`, "https://vault.example/enroll?state=opaque"} { + assert.Contains(t, out, want) + } + assert.NotContains(t, out, "secret-capture") + + _, text, err := executeVaultCommand(t, client, "vaults", "wallets", "payment-methods", "checkout", "cardholder") + require.NoError(t, err) + for _, want := range []string{"pending_enrollment", "awaiting_cardholder", "card_enrollment url", "payment_method_id explicitly", "removal_pending"} { + assert.Contains(t, text, want) + } + assert.NotContains(t, text, "secret-capture") +} + +func TestVaultPaymentMethodOperationHelp(t *testing.T) { + invoke, _, err := newVaultsCommand().Find([]string{"items", "invoke"}) + require.NoError(t, err) + flat := strings.Join(strings.Fields(invoke.Long), " ") + for _, want := range []string{"add_payment_method takes no parameters", "remove_payment_method requires", "payment_method_id", "expires after 30 minutes", "separate pending enrollment", "without disconnecting the wallet", "removal_pending", "not proof that a payment did not occur", "vaults:write"} { + assert.Contains(t, flat, want) + } + assert.Contains(t, invoke.Example, "add_payment_method") + assert.Contains(t, invoke.Example, "remove_payment_method") + assert.Contains(t, invoke.Flags().Lookup("params").Usage, "remove_payment_method") +} diff --git a/cmd/vaults_payment_request_test.go b/cmd/vaults_payment_request_test.go new file mode 100644 index 00000000..8ea51c60 --- /dev/null +++ b/cmd/vaults_payment_request_test.go @@ -0,0 +1,138 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const linkPaymentRequestFixture = `{ + "id":"payment-request-id","key":"order-1","type":"payment_request", + "spec":{"provider":"link","wallet":"wallet-1","payment_method_id":"pm-1","amount":1234,"currency":"usd","merchant_name":"Example Shop","merchant_url":"https://shop.example/checkout","context":"Purchase purpose. Purchase purpose. Purchase purpose. Purchase purpose. Purchase purpose. Purchase purpose. Purchase purpose."}, + "state":{"provider":"link","status":"requested"}, + "available_operations":[{"type":"authorize","description":"Authorize this purchase"}], + "available_expansions":[] +}` + +const kernelPaymentRequestFixture = `{ + "id":"payment-request-id","key":"order-1","type":"payment_request", + "spec":{"provider":"kernel","wallet":"cardholder","payment_method_id":"card-1","amount":1234,"currency":"usd","merchant_name":"Example Shop","merchant_url":"https://shop.example/checkout","merchant_country":"US"}, + "state":{"provider":"kernel","status":"requested","funding_status":"available","payment_method_id":"card-1","masks":{"brand":"visa","last4":"1234","token_last4":"5678"}}, + "available_operations":[{"type":"authorize","description":"Authorize this purchase"}], + "available_expansions":[] +}` + +func TestVaultPaymentRequestCreateAndUpdate(t *testing.T) { + t.Setenv("KERNEL_PROJECT", "") + for _, operation := range []string{"create", "update"} { + for _, provider := range []string{"link", "kernel"} { + t.Run(operation+"/"+provider, func(t *testing.T) { + fixture := linkPaymentRequestFixture + spec := `{"wallet":"wallet-1","payment_method_id":"pm-1","amount":1234,"currency":"usd","merchant_name":"Example Shop","merchant_url":"https://shop.example/checkout","context":"Purchase purpose. Purchase purpose. Purchase purpose. Purchase purpose. Purchase purpose. Purchase purpose. Purchase purpose.","metadata":{"reference":"order-1"}}` + if provider == "kernel" { + fixture = kernelPaymentRequestFixture + spec = `{"wallet":"cardholder","payment_method_id":"card-1","amount":1234,"currency":"usd","merchant_name":"Example Shop","merchant_url":"https://shop.example/checkout","merchant_country":"US"}` + } + calls := 0 + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + calls++ + expectedMethod := http.MethodPut + if operation == "update" { + expectedMethod = http.MethodPatch + } + assert.Equal(t, expectedMethod, r.Method) + assert.Equal(t, "/vaults/checkout/items/order-1", r.URL.Path) + 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, "payment_request", body.Type) + assert.JSONEq(t, fmt.Sprintf("%q", provider), string(body.Spec["provider"])) + expectedMethodID := `"card-1"` + if provider == "link" { + expectedMethodID = `"pm-1"` + } + assert.JSONEq(t, expectedMethodID, string(body.Spec["payment_method_id"])) + if provider == "link" { + assert.JSONEq(t, `{"reference":"order-1"}`, string(body.Spec["metadata"])) + } + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, fixture) + }) + out, _, err := executeVaultCommand(t, client, "vaults", "payment-requests", operation, "checkout", "order-1", "--provider", provider, "--spec", spec, "-o", "json") + require.NoError(t, err) + assert.Equal(t, 1, calls) + assert.Contains(t, out, `"type": "payment_request"`) + assert.Contains(t, out, `"payment_method_id"`) + }) + } + } +} + +func TestVaultPaymentRequestRejectsAgentCard(t *testing.T) { + calls := 0 + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { calls++ }) + _, _, err := executeVaultCommand(t, client, "vaults", "payment-requests", "create", "checkout", "order-1", "--provider", "agentcard", "--spec", `{}`) + require.ErrorContains(t, err, "support only link or kernel") + assert.Zero(t, calls) +} + +func TestVaultPaymentRequestOutputAndLinkAuthorize(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, linkPaymentRequestFixture) + return + } + body, err := io.ReadAll(r.Body) + require.NoError(t, err) + assert.JSONEq(t, `{"type":"authorize","browser_id":"browser-1","page_url":"https://shop.example/checkout"}`, string(body)) + _, _ = io.WriteString(w, strings.Replace(linkPaymentRequestFixture, `"status":"requested"`, `"status":"pending_authorization"`, 1)) + }) + out, human, err := executeVaultCommand(t, client, "vaults", "items", "get", "checkout", "order-1") + require.NoError(t, err) + for _, want := range []string{"payment_request", "payment method id", "pm-1", "wallet-1", "authorize"} { + assert.Contains(t, out+human, want) + } + _, _, err = executeVaultCommand(t, client, "vaults", "items", "invoke", "checkout", "order-1", "authorize", "--browser-id", "browser-1", "--page-url", "https://shop.example/checkout", "-o", "json") + require.NoError(t, err) + assert.Equal(t, 3, calls) +} + +func TestVaultKernelPaymentRequestFundingOutput(t *testing.T) { + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, kernelPaymentRequestFixture) + }) + out, human, err := executeVaultCommand(t, client, "vaults", "items", "get", "checkout", "order-1", "-o", "json") + require.NoError(t, err) + assert.Contains(t, out, `"funding_status": "available"`) + assert.Contains(t, out, `"payment_method_id": "card-1"`) + _, human, err = executeVaultCommand(t, client, "vaults", "items", "get", "checkout", "order-1") + require.NoError(t, err) + for _, want := range []string{"funding status", "available", "card-1", "network token last4", "5678", "funding_status is separate from purchase status", "removed means the method is gone"} { + assert.Contains(t, human, want) + } +} + +func TestVaultPaymentRequestHelpAndFlags(t *testing.T) { + for _, path := range [][]string{{"payment-requests", "create"}, {"payment-requests", "update"}} { + cmd, _, err := newVaultsCommand().Find(path) + require.NoError(t, err) + assert.NotNil(t, cmd.Flags().Lookup("provider")) + assert.NotNil(t, cmd.Flags().Lookup("spec")) + for _, want := range []string{"payment_request", "LinkPaymentRequestSpec", "KernelPaymentRequestSpec", "payment_method_id", "payment-requests", "AgentCard reusable", "funding_status is separate from purchase status"} { + assert.Contains(t, cmd.Long, want) + } + assert.Contains(t, cmd.Example, `"payment_method_id"`) + } +} diff --git a/cmd/vaults_webmcp.go b/cmd/vaults_webmcp.go index 7cca5641..cd0d438c 100644 --- a/cmd/vaults_webmcp.go +++ b/cmd/vaults_webmcp.go @@ -11,7 +11,6 @@ import ( "strings" kernel "github.com/kernel/kernel-go-sdk" - "github.com/kernel/kernel-go-sdk/option" "github.com/pterm/pterm" "github.com/spf13/cobra" ) @@ -220,7 +219,7 @@ func vaultWebMCPNullSlot(input any, path string) bool { } 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)) + response, err := c.vaults.Items.PerformOperation(ctx, key, kernel.VaultItemPerformOperationParams{IDOrName: vault, OfWebmcpInvoke: request}, vaultWriteRequestOptions()...) if err != nil { return vaultOperationRequestError(err, "webmcp_invoke", vaultWebMCPErrorMessages, vaultWebMCPRejected, vaultWebMCPUncertain) } @@ -281,7 +280,7 @@ func newVaultWebMCPCommand() *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. + Long: `Invoke a live WebMCP tool with values from a credential item or ready Link card/payment_request item. This sends the advertised webmcp_invoke operation; it is equivalent to items invoke webmcp_invoke --spec-file with the same parameters. diff --git a/go.mod b/go.mod index 73f72506..a0939614 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.131.0 + github.com/kernel/kernel-go-sdk v0.135.0 github.com/klauspost/compress v1.18.5 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c github.com/pterm/pterm v0.12.80 @@ -41,6 +41,7 @@ require ( github.com/danwakefield/fnmatch v0.0.0-20160403171240-cbb64ac3d964 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/godbus/dbus/v5 v5.1.0 // indirect + github.com/google/uuid v1.6.0 // indirect github.com/gookit/color v1.5.4 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/lithammer/fuzzysearch v1.1.8 // indirect diff --git a/go.sum b/go.sum index 2b7257a1..96017114 100644 --- a/go.sum +++ b/go.sum @@ -58,6 +58,8 @@ github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg= github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gookit/color v1.4.2/go.mod h1:fqRyamkC1W8uxl+lxCQxOT09l/vYfZ+QeiX3rKQHCoQ= github.com/gookit/color v1.5.0/go.mod h1:43aQb+Zerm/BWh2GnrgOQm7ffz7tvQXEKV6BFMl7wAo= github.com/gookit/color v1.5.4 h1:FZmqs7XOyGgCAxmWyPslpiok1k05wmY3SJTytgvYFs0= @@ -66,8 +68,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.131.0 h1:9aYi3O5N8g/cNwAIevmL+lOy8yy6daz5EXciCPipLco= -github.com/kernel/kernel-go-sdk v0.131.0/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= +github.com/kernel/kernel-go-sdk v0.135.0 h1:wOID4GW1Hb73wtkEZpEYOfYInWTCQgCZ9eRpidEytk8= +github.com/kernel/kernel-go-sdk v0.135.0/go.mod h1:IoxXdj7vgL3LwtAASaRDhs4qgp9AXwBuB3+3EiRBB+U= 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=